LR-IMP-LOCAL: browse folders on disk without adding them (Local), failed renders aren't retried

- library.browse {path, subfolders}: a folder's photos are read in place as "local" photos
  (catalog flag, SetLocal op) that only folder views list — not All Photos, albums, date
  groups, keyword counts or stats. photo.addToLibrary promotes them; importing the folder for
  real promotes instead of reporting duplicates. The browsed folder persists with the view.
- UI: Local section (Pictures, Desktop, Downloads, Home, the browsed folder, Browse Folder…);
  folder views get a breadcrumb header (each part opens that folder), Include subfolders and
  "Add N to My Photos".
- Renderer: a job that fails (unreadable / missing file) is remembered by slot + key and not
  requested again every frame — the grid re-queued it forever (a render loop per broken
  file). The loupe says "This photo can't be opened" with the reason; grid cells show "!".
  ui.inspect lists pending render slots.
- Headless app.export (MCP, lightcraft-cli run) exports the selection like the desktop app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:53:58 -04:00
parent 096aefe698
commit 71dc30b951
23 changed files with 451 additions and 32 deletions

View File

@ -75,7 +75,7 @@ impl Catalog {
}
let mut roots = Vec::new();
let mut seen = std::collections::HashSet::new();
for p in self.photos().filter(|p| !p.deleted) {
for p in self.photos().filter(|p| p.in_library()) {
for k in &p.meta.keywords {
let parts: Vec<&str> = k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect();
insert(&mut roots, &parts, "", p.id.0, &mut seen);
@ -162,7 +162,7 @@ impl Catalog {
} else {
let mut co: std::collections::HashMap<String, i64> = Default::default();
if !current.is_empty() {
for p in self.photos().filter(|p| !p.deleted) {
for p in self.photos().filter(|p| p.in_library()) {
if p.meta.keywords.iter().any(|k| has(k)) {
for k in p.meta.keywords.iter().filter(|k| !has(k)) {
*co.entry(k.clone()).or_default() += 1;

View File

@ -84,6 +84,11 @@ pub enum Op {
id: PhotoId,
deleted: bool,
},
/// Browsed-only (`true`) or part of the library.
SetLocal {
id: PhotoId,
local: bool,
},
SetVersions {
id: PhotoId,
versions: Vec<Version>,
@ -339,6 +344,10 @@ impl Catalog {
let p = self.photo_mut(id)?;
Op::SetDeleted { id, deleted: std::mem::replace(&mut p.deleted, deleted) }
}
Op::SetLocal { id, local } => {
let p = self.photo_mut(id)?;
Op::SetLocal { id, local: std::mem::replace(&mut p.local, local) }
}
Op::SetVersions { id, versions } => {
let p = self.photo_mut(id)?;
Op::SetVersions { id, versions: std::mem::replace(&mut p.versions, versions) }

View File

@ -168,6 +168,10 @@ pub struct Photo {
/// In "Recently Deleted".
#[serde(default)]
pub deleted: bool,
/// Seen while browsing a folder on disk (Local), not added to the library: only folder views
/// show it until it is added.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub local: bool,
/// Video duration in seconds.
#[serde(default)]
pub duration: Option<f64>,
@ -217,6 +221,7 @@ impl Photo {
versions: Vec::new(),
history: Vec::new(),
deleted: false,
local: false,
duration: None,
as_shot_wb: None,
content_hash: None,
@ -246,6 +251,10 @@ impl Photo {
}
d
}
/// In the library: not deleted and not only browsed (Local).
pub fn in_library(&self) -> bool {
!self.deleted && !self.local
}
/// Edited by the user: settings differ from what import gave the photo.
pub fn is_edited(&self) -> bool {
*self.develop != self.import_defaults() && !self.develop.is_unedited()

View File

@ -42,6 +42,9 @@ pub struct Filter {
pub date_to: Option<String>,
/// Import date prefix (Recently Added).
pub imported: Option<String>,
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
pub folder: Option<String>,
pub subfolders: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -156,6 +159,17 @@ impl Filter {
if p.deleted != self.deleted {
return false;
}
match &self.folder {
// browsed photos only show in folder views
None if p.local => return false,
None => {}
Some(dir) => {
let crate::Source::File { path } = &p.source else { return false };
if !in_folder(path, dir, self.subfolders) {
return false;
}
}
}
if self.rating > 0 {
let ok = match self.rating_op {
RatingOp::AtLeast => p.rating >= self.rating,
@ -239,7 +253,7 @@ impl Catalog {
pub fn date_groups(&self) -> Vec<DateGroup> {
use std::collections::BTreeMap;
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
for p in self.photos().filter(|p| !p.deleted) {
for p in self.photos().filter(|p| p.in_library()) {
let d = p.date();
if d.len() >= 10 {
let (months, days) = years.entry(&d[..4]).or_default();
@ -263,7 +277,7 @@ impl Catalog {
/// All keywords with usage counts, sorted by name.
pub fn keywords(&self) -> Vec<(String, usize)> {
let mut m: std::collections::BTreeMap<String, usize> = Default::default();
for p in self.photos().filter(|p| !p.deleted) {
for p in self.photos().filter(|p| p.in_library()) {
for k in &p.meta.keywords {
*m.entry(k.clone()).or_default() += 1;
}
@ -281,3 +295,12 @@ pub struct DateGroup {
/// `YYYY-MM-DD` and counts, newest first.
pub days: Vec<(String, usize)>,
}
/// Whether file `path` is directly in `dir` (or anywhere below it with `deep`). Both `/` and `\\`
/// separate.
pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
let dir = dir.trim_end_matches(['/', '\\']);
let Some(rest) = path.strip_prefix(dir) else { return false };
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}

View File

@ -205,3 +205,14 @@ fn capture_times_display_like_the_info_panel() {
assert_eq!(display_time("2022-07-04"), "July 4, 2022");
assert_eq!(display_time("someday"), "someday");
}
#[test]
fn folder_paths() {
use crate::query::in_folder;
assert!(in_folder("/a/b/c.jpg", "/a/b", false));
assert!(in_folder("/a/b/c.jpg", "/a/b/", false));
assert!(!in_folder("/a/b/x/c.jpg", "/a/b", false));
assert!(in_folder("/a/b/x/c.jpg", "/a/b", true));
assert!(!in_folder("/a/bc/d.jpg", "/a/b", true), "a sibling with the same prefix");
assert!(in_folder("C:\\Pics\\a.jpg", "C:\\Pics", false));
}

View File

@ -0,0 +1,69 @@
//! Local: browse a folder on disk without adding it to the library (Lightroom's Local tab).
//! The folder's photos are probed in place as `local` photos — only folder views list them —
//! and edits go to the files' XMP sidecars like any photo added in place. "Add to My Photos"
//! makes them ordinary library photos.
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
use crate::import::{ImportMode, ImportOptions, import_with, is_supported};
use crate::{Browse, LibrarySource, Result, Session};
fn browse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.browse";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let dir = std::path::absolute(Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
if !dir.is_dir() {
return Err(bad(C, format!("{path}: not a folder")));
}
let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
let subfolders = bool_or(p, "subfolders", s.browse.as_ref().is_some_and(|b| b.subfolders));
let files: Vec<String> = if subfolders {
crate::import::expand(std::slice::from_ref(&dir_s), None)
} else {
let mut v: Vec<String> = std::fs::read_dir(&dir)
.map_err(|e| bad(C, format!("{path}: {e}")))?
.flatten()
.map(|e| e.path())
.filter(|f| f.is_file() && is_supported(f) && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')))
.map(|f| f.to_string_lossy().to_string())
.collect();
v.sort();
v
};
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
let shown = s.visible().len();
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
}
/// The browsed photos among `ids` (default: the selection) become library photos.
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
if ids.is_empty() {
return Ok(json!({"added": 0}));
}
let n = ids.len();
let ops = ids.into_iter().map(|id| Op::SetLocal { id, local: false }).collect();
s.commit("Add to My Photos", Op::Batch { ops })?;
Ok(json!({"added": n}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"library.browse",
"Browse Folder",
[],
None,
"{path, subfolders?: bool} — show a folder's photos without adding them to the library (they're read in place; edits go to XMP sidecars) → {path, photos, new}",
always,
browse
),
cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library),
]
}

View File

@ -568,7 +568,7 @@ pub fn specs() -> Vec<CommandSpec> {
{
return Err(bad("library.import", "album must be a regular album"));
}
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords") };
let opts = crate::import::ImportOptions { mode, preset, keywords: strs(p, "keywords"), ..Default::default() };
let undo0 = s.undo.len();
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();

View File

@ -5,6 +5,7 @@
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
mod before;
mod browse;
mod color;
mod develop;
mod edit;
@ -121,6 +122,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(prefs::specs());
v.extend(export::specs());
v.extend(before::specs());
v.extend(browse::specs());
v
})
}

View File

@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"total": ids.len(), "photos": items}))
}),
cmd!(query "catalog.stats", "Catalog Statistics", [], None, "{}", always, |s, _| {
let all: Vec<_> = s.catalog.photos().filter(|p| !p.deleted).collect();
let all: Vec<_> = s.catalog.photos().filter(|p| p.in_library()).collect();
Ok(json!({
"photos": all.len(),
"edited": all.iter().filter(|p| p.is_edited()).count(),

View File

@ -49,6 +49,9 @@ pub struct ImportOptions {
pub preset: Option<lightcraft_develop::Preset>,
/// Added to every imported photo.
pub keywords: Vec<String>,
/// Browsing a folder: photos come in as `local` (not in the library), and a file with the
/// same content as one already known is still listed.
pub local: bool,
}
/// A file found by [`scan`], for the import review.
@ -372,8 +375,14 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
}
}
let mut todo = Vec::new();
// a file that was only browsed (Local) joins the library when it is imported for real
let mut promote = Vec::new();
for f in files {
match by_path.get(f.as_str()) {
Some(id) if !opts.local && s.catalog.photo(*id).is_some_and(|p| p.local) => {
promote.push(Op::SetLocal { id: *id, local: false });
report.imported.push(id.0);
}
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
None => todo.push(f),
}
@ -382,7 +391,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
let probed = probe_all(s, &todo);
crate::memory::release();
let now = (s.clock)();
let mut ops = Vec::new();
let mut ops = promote;
for (path, info) in todo.into_iter().zip(probed) {
let info = match info {
Ok(i) => i,
@ -393,6 +402,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
}
};
if let Some(h) = &info.content_hash
&& !opts.local
&& let Some(id) = by_hash.get(h)
{
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
@ -452,6 +462,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
p.history.push(lightcraft_catalog::HistoryStep { label, settings: d });
}
report.imported.push(id.0);
p.local = opts.local;
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
if !ops.is_empty() {

View File

@ -33,7 +33,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
pub use media::{RenderJob, SourceLevel};
use serde_json::Value;
pub use view::{LibrarySource, Selection};
pub use view::{Browse, LibrarySource, Selection};
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
#[derive(Debug, thiserror::Error)]
@ -81,6 +81,8 @@ pub struct Session {
pub interaction: Option<Interaction>,
/// Copied develop settings (partial JSON) for Paste.
pub clipboard: Option<Value>,
/// The folder on disk the [`LibrarySource::Folder`] view browses.
pub browse: Option<Browse>,
/// 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).
@ -144,6 +146,7 @@ impl Session {
interaction: None,
clipboard: None,
meta_clipboard: None,
browse: None,
previous_active: None,
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
presets: presets::builtin(),
@ -356,9 +359,15 @@ impl Session {
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
pub fn visible(&mut self) -> &[PhotoId] {
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.filter, self.sort));
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
if self.visible_key.as_ref() != Some(&key) {
let f = self.source.to_filter(&self.filter, &self.catalog);
let mut f = self.source.to_filter(&self.filter, &self.catalog);
if self.source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
}
self.visible = self.catalog.query(&f, &self.sort);
if matches!(self.source, LibrarySource::Album(_))
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate

View File

@ -81,6 +81,7 @@ struct PresetsFile {
#[serde(default)]
struct ViewFile {
source: LibrarySource,
browse: Option<crate::Browse>,
filter: lightcraft_catalog::Filter,
sort: lightcraft_catalog::Sort,
selection: Selection,
@ -178,6 +179,7 @@ impl Session {
// view state
if let Some(v) = read_json::<ViewFile>(files.as_mut(), "view.json") {
self.source = v.source;
self.browse = v.browse;
self.filter = v.filter;
self.sort = v.sort;
self.selection = v.selection;
@ -251,7 +253,13 @@ impl Session {
}
fn view_json(&self) -> Vec<u8> {
let view = ViewFile { source: self.source, filter: self.filter.clone(), sort: self.sort, selection: self.selection.clone() };
let view = ViewFile {
source: self.source,
browse: self.browse.clone(),
filter: self.filter.clone(),
sort: self.sort,
selection: self.selection.clone(),
};
serde_json::to_vec_pretty(&view).unwrap_or_default()
}

View File

@ -101,3 +101,45 @@ fn civil_dates() {
assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00");
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
}
#[test]
fn browsing_a_folder_lists_its_photos_without_adding_them() {
use crate::LibrarySource;
let dir = std::env::temp_dir().join(format!("lc-browse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
let png = |p: &std::path::Path, seed: u8| {
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
.unwrap();
std::fs::write(p, b).unwrap();
};
png(&dir.join("a.png"), 1);
png(&dir.join("b.png"), 2);
png(&dir.join("sub/c.png"), 3);
std::fs::write(dir.join("notes.txt"), "x").unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy()})).unwrap();
assert_eq!(r["photos"], 2, "{r}");
assert_eq!(s.source, LibrarySource::Folder);
assert_eq!(s.visible_cloned().len(), 2);
// not in the library
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
assert!(s.visible_cloned().is_empty(), "browsed photos stay out of All Photos");
assert_eq!(s.execute("catalog.stats", &serde_json::json!({})).unwrap()["photos"], 0, "nor in the counts");
assert!(s.catalog.date_groups().is_empty());
// subfolders; browsing again reuses the photos
let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy(), "subfolders": true})).unwrap();
assert_eq!((r["photos"].as_u64(), r["new"].as_u64()), (Some(3), Some(1)));
// add one to the library
let first = s.visible_cloned()[0];
s.execute("photo.addToLibrary", &serde_json::json!({"ids": [first.0]})).unwrap();
// importing the folder for real brings in the rest (no duplicates of the browsed ones)
let r = s.execute("library.import", &serde_json::json!({"paths": [dir.to_string_lossy()]})).unwrap();
assert_eq!(r["imported"].as_array().map(Vec::len), Some(2), "{r}");
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
assert_eq!(s.visible_cloned().len(), 3);
assert_eq!(s.catalog.photos().count(), 3);
assert!(s.execute("library.browse", &serde_json::json!({"path": dir.join("a.png").to_string_lossy()})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -15,6 +15,16 @@ pub enum LibrarySource {
RecentlyDeleted,
/// Photos with picks.
Picks,
/// A folder on disk ([`crate::Session::browse`]): its files, added to the library or not.
Folder,
}
/// The folder a [`LibrarySource::Folder`] view shows.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Browse {
pub path: String,
pub subfolders: bool,
}
impl LibrarySource {
@ -29,6 +39,8 @@ impl LibrarySource {
LibrarySource::Album(a) => f.album = Some(*a),
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 => {}
}
f
}
@ -40,6 +52,7 @@ impl LibrarySource {
LibrarySource::Album(a) => cat.album(*a).map(|a| a.name.clone()).unwrap_or_else(|| "Album".into()),
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
LibrarySource::Picks => "Picks".into(),
LibrarySource::Folder => "Folder".into(),
}
}
}

View File

@ -70,6 +70,11 @@ impl Headless {
}
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
// like the desktop app: the selection (in grid order), else the given / active photo
None if p.get("id").is_none() && self.session.selection.ids.len() > 1 => {
let sel: std::collections::HashSet<_> = self.session.selection.ids.iter().copied().collect();
self.session.visible_cloned().into_iter().filter(|id| sel.contains(id)).collect()
}
None => vec![self.photo_or_active(p)?],
};
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
@ -180,3 +185,29 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String>
let bytes = encode_image(&ext, img, quality)?;
std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
/// `app.export` without `ids` exports the selection (as the desktop app does), else the active photo.
#[test]
fn export_follows_the_selection() {
let dir = std::env::temp_dir().join(format!("lc-mcp-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut h = Headless::demo();
let ids: Vec<u64> = h.session.visible_cloned().iter().take(3).map(|p| p.0).collect();
h.call("engine.execute", json!({"command": "library.select", "params": {"ids": ids}})).unwrap();
let r = h
.call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}}))
.unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}");
// one photo selected: that one
h.call("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0]]}})).unwrap();
let r = h
.call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}}))
.unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
"activeMask": app.session.active_mask,
"widgetCount": app.widgets.len(),
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()},
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
"status": app.ui.status,

View File

@ -667,6 +667,45 @@ mod tests {
assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt"));
}
/// Local: a folder's photos show without joining the library; the breadcrumb, Include
/// subfolders and Add to My Photos work from the grid header.
#[test]
fn browse_a_local_folder() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-ui-browse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("inner")).unwrap();
let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]);
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
for (i, p) in [dir.join("a.png"), dir.join("inner/b.png")].iter().enumerate() {
let mut img = img.clone();
img.data[0][0] = i as u8; // different bytes per file
std::fs::write(p, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
}
let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count();
let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t);
assert_eq!(r["result"]["photos"], 1, "{r}");
assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy");
// this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried
let id = h.app.session.visible_cloned()[0];
assert!(h.app.renderer.failure(crate::render::Slot::Thumb(id)).is_some());
let done = h.app.renderer.completed;
for _ in 0..30 {
h.step();
}
assert_eq!(h.app.renderer.completed, done, "no retry loop");
let w = h.request("ui.widgets", json!({"filter": "crumb:"}), t);
assert!(w["result"].as_array().is_some_and(|a| !a.is_empty()), "breadcrumb: {w}");
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:includeSubfolders"}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.app.session.visible_cloned().len(), 2);
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2);
let _ = std::fs::remove_dir_all(&dir);
}
/// ⌘Q (File → Quit LightCraft) closes the window.
#[test]
fn cmd_q_quits() {

View File

@ -271,7 +271,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
} else {
shown = draw(Slot::Main, img_rect);
if shown == "none" {
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
match app.renderer.failure(Slot::Main) {
Some(e) => {
p.text(canvas.center() - vec2(0.0, 10.0), Align2::CENTER_CENTER, "This photo can't be opened", t.semibold(14.0), t.text);
p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim);
}
None => {
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
}
}
}
}
app.loupe_shown = Some((id, shown));

View File

@ -223,7 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// metadata pickers
let ui: &mut egui::Ui = if two_rows { &mut row2 } else { &mut row1 };
let photos: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).cloned().collect();
let photos: Vec<_> = app.session.catalog.photos().filter(|p| p.in_library()).cloned().collect();
let cameras = distinct(photos.iter().map(|p| p.meta.camera.clone()).collect());
let lenses = distinct(photos.iter().map(|p| p.meta.lens.clone()).collect());
let keywords: Vec<String> = app.session.catalog.keywords().into_iter().map(|(k, _)| k).collect();

View File

@ -30,18 +30,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let ids = app.session.visible_cloned();
// header: source title + count
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
let title = app.session.source.label(&app.session.catalog);
ui.painter().rect_filled(hr, 0.0, t.canvas);
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
let sel_n = app.session.selection.ids.len();
let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) };
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) {
Some(b) => folder_header(app, ui, hr, &b, &ids, &cnt),
None => {
let title = app.session.source.label(&app.session.catalog);
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);
}
}
if app.ui.filter_bar {
super::filterbar::show(app, ui);
}
app.canvas_rect = Some(ui.max_rect());
if ids.is_empty() {
if app.session.filter != Default::default() {
if app.session.source == lightcraft_engine::LibrarySource::Folder {
super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local");
} else if app.session.filter != Default::default() {
super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)");
} else {
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
@ -300,7 +307,12 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
p.rect_stroke(fit, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside);
}
} else {
p.rect_filled(img_rect.shrink(if square { 20.0 } else { 0.0 }), 0.0, Color32::from_gray(38));
let ph = img_rect.shrink(if square { 20.0 } else { 0.0 });
p.rect_filled(ph, 0.0, Color32::from_gray(38));
if app.renderer.failure(Slot::Thumb(id)).is_some() {
// unreadable / missing file
p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text_dim);
}
}
// labels and badges
if square && app.ui.show_filenames {
@ -387,6 +399,58 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
resp.context_menu(|ui| context_menu(app, ui, id));
}
/// The header of a Local folder view: the path as a breadcrumb (each part opens that folder),
/// Include subfolders, Add to My Photos.
fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], cnt: &str) {
let t = Tokens::get(ui.ctx());
let mut child =
ui.new_child(egui::UiBuilder::new().max_rect(hr.shrink2(vec2(16.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.spacing_mut().item_spacing.x = 4.0;
let parts: Vec<&str> = b.path.split(['/', '\\']).filter(|p| !p.is_empty()).collect();
// the last few parts (the root side is elided)
let skip = parts.len().saturating_sub(4);
if skip > 0 {
child.label(egui::RichText::new("… ›").color(t.text_dim));
}
for (i, part) in parts.iter().enumerate().skip(skip) {
let last = i + 1 == parts.len();
let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label });
let r = child.add(egui::Label::new(if last { text.strong() } else { text }).sense(Sense::click()));
register(child.ctx(), format!("crumb:{i}"), r.rect);
if !last {
if r.hovered() {
child.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked() {
let prefix = if b.path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") };
let _ = app.run("library.browse", json!({"path": prefix}));
}
child.label(egui::RichText::new("›").color(t.text_dim));
}
}
child.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(cnt).size(12.5).color(t.text_dim));
ui.add_space(10.0);
let local: Vec<u64> = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect();
if !local.is_empty() {
let label = format!("Add {} to My Photos", local.len());
if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() {
let n = local.len();
match app.run("photo.addToLibrary", json!({"ids": local})) {
Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" })),
Err(e) => app.toast(ui.ctx(), e),
}
}
}
let mut sub = b.subfolders;
let c = ui.checkbox(&mut sub, "Include subfolders");
register(ui.ctx(), "check:includeSubfolders", c.rect);
if c.changed() {
let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub}));
}
});
}
/// While photos are dragged: a badge at the pointer; the drag ends when the button is up
/// (drop targets act on the release frame, before this runs).
pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {

View File

@ -59,10 +59,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text);
let stats: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).map(|p| p.flag).collect();
let stats: Vec<_> = app.session.catalog.photos().filter(|p| p.in_library()).map(|p| p.flag).collect();
let total = stats.len();
let picks = stats.iter().filter(|f| **f == lightcraft_catalog::Flag::Pick).count();
let deleted = app.session.catalog.photos().filter(|p| p.deleted).count();
let deleted = app.session.catalog.photos().filter(|p| p.deleted && !p.local).count();
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
let src = app.session.source;
for (id, icon, label, count, s) in [
@ -98,6 +98,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let albums: Vec<Album> = app.session.catalog.albums().cloned().collect();
albums_tree(app, ui, &albums, None, 0.0);
ui.add_space(10.0);
local_section(app, ui);
// By date
let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label);
@ -128,6 +129,51 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
}
/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop,
/// Downloads, the home folder, the folder being browsed, and Browse Folder….
fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if cfg!(target_arch = "wasm32") {
return;
}
let t = Tokens::get(ui.ctx());
let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, "Local", t.semibold(13.5), t.text_label);
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let mut places: Vec<(String, String)> = Vec::new();
if !home.is_empty() {
for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] {
let p = if sub.is_empty() { home.clone() } else { format!("{home}/{sub}") };
if std::path::Path::new(&p).is_dir() {
places.push((name.to_string(), p));
}
}
}
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
if let Some(b) = &browsing
&& !places.iter().any(|(_, p)| *p == b.path)
{
let name = std::path::Path::new(&b.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| b.path.clone());
places.push((name, b.path.clone()));
}
for (name, path) in places {
let sel = browsing.as_ref().is_some_and(|b| b.path == path);
if row(app, ui, &format!("local:{path}"), Icon::Folder, &name, None, sel, 0.0).on_hover_text(&path).clicked()
&& let Err(e) = app.run("library.browse", json!({"path": path}))
{
app.toast(ui.ctx(), e);
}
}
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() {
let picked = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(path) = picked
&& let Err(e) = app.run("library.browse", json!({"path": path}))
{
app.toast(ui.ctx(), e);
}
}
ui.add_space(10.0);
}
/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open.
fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool {
let t = Tokens::get(ui.ctx());

View File

@ -101,6 +101,9 @@ pub struct Renderer {
stages: HashMap<Slot, Arc<StageCache>>,
/// Slot → key of the last quick job requested for it (each is tried once).
quick_tried: HashMap<Slot, u64>,
/// Jobs that failed (unreadable or missing file…), by slot and job key, with the error: not
/// requested again until the key changes (an edit, another size) — the grid asks every frame.
failed: HashMap<Slot, (u64, String)>,
/// Prefetch slot → key of the last job submitted (each runs once).
prefetched: HashMap<Slot, u64>,
/// Variant key → frame it was last asked for (LRU of [`Slot::Variant`] textures).
@ -131,6 +134,7 @@ impl Default for Renderer {
keep_pixels: false,
stages: HashMap::new(),
quick_tried: HashMap::new(),
failed: HashMap::new(),
prefetched: HashMap::new(),
variant_used: HashMap::new(),
frame: 0,
@ -157,7 +161,7 @@ impl Renderer {
/// Request a render for `slot` (no-op if already current or pending at the same priority).
pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) {
if self.textures.get(&slot).is_some_and(|t| t.key == job.key) {
if self.textures.get(&slot).is_some_and(|t| t.key == job.key) || self.failed.get(&slot).is_some_and(|f| f.0 == job.key) {
return;
}
if let Some(&(key, prio)) = self.pending.get(&slot)
@ -216,6 +220,7 @@ impl Renderer {
self.textures.clear();
self.stages.clear();
self.quick_tried.clear();
self.failed.clear();
self.prefetched.clear();
self.pending.clear();
self.queue.clear();
@ -251,6 +256,16 @@ impl Renderer {
self.pending.len()
}
/// Why the last render for `slot` failed (until a render with another key succeeds).
pub fn failure(&self, slot: Slot) -> Option<&str> {
self.failed.get(&slot).map(|f| f.1.as_str())
}
/// The slots with a render pending (diagnostics: `ui.inspect`).
pub fn pending_slots(&self) -> Vec<String> {
self.pending.keys().map(|s| format!("{s:?}")).collect()
}
/// Thumbnail textures currently loaded.
/// A variant thumbnail ([`Slot::Variant`]): its texture once rendered; until then the job is
/// queued at low priority (below on-screen grid thumbnails). Call it every frame the variant
@ -343,8 +358,18 @@ impl Renderer {
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
self.pending.remove(&slot);
}
let Ok(rendered) = r.rendered else {
continue;
let rendered = match r.rendered {
Ok(x) => {
self.failed.remove(&slot);
x
}
Err(e) => {
if !matches!(slot, Slot::Prefetch(_)) {
log::warn!("render {slot:?}: {e}");
self.failed.insert(slot, (r.key, e));
}
continue;
}
};
if matches!(slot, Slot::Prefetch(_)) {
continue;

View File

@ -21,7 +21,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
<!-- parity:summary -->
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---:|---:|---:|---:|---:|---:|
| A. Import (IMP) | 6 | 3 | 4 | 1 | 2/4 (50%) | 3/4 (75%) |
| A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) |
| B. Library management (LIB) | 18 | 1 | 6 | 2 | 9/9 (100%) | 8/9 (89%) |
| C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) |
| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) |
@ -45,12 +45,12 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) |
| Y. Menus | 68 | 9 | 8 | 8 | 46/47 (98%) | 17/23 (74%) |
| Y. Menus | 69 | 8 | 8 | 8 | 46/47 (98%) | 18/23 (78%) |
| Z. Keyboard shortcuts (desktop) | 63 | 12 | 5 | 1 | 49/52 (94%) | 12/23 (52%) |
| Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) |
| **Total** | 306 | 64 | 130 | 37 | 192/198 (97%) | 95/144 (66%) |
| Lightroom Classic extras | 7 | 20 | 52 | 9 | — | 6/21 (29%) |
| **Total** | 308 | 63 | 129 | 37 | 193/198 (97%) | 96/144 (67%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **67.6%** of 500 in-scope rows — P0 98.5% of 198 · P1 79.9% of 144 · P2 17.7% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **67.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 80.6% of 144 · P2 17.7% of 158.
<!-- /parity:summary -->
## Top gaps
@ -107,7 +107,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `cmd:library.importPreview`, `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; the import review shows a folder's photos before adding (“Add in place” or “Copy into library”); no browse-a-folder-without-adding view in the main grid |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | |
@ -547,7 +547,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | |
| MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | |
| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor` | colour cycles through the panel's swatches (no params) or takes `color` / `opacity` |
| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive |
| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | ✅ | `cmd:library.browse` (`subfolders`) | toggle in the folder header; imports of folders are recursive |
| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key |
| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | |
| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | 🟡 | `crates/ui-egui/src/panels/left.rs` | counts always shown; no toggle |
@ -693,7 +693,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
| LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | |
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets |
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ⬜ | | |
| LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move |
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections |
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ |
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |