diff --git a/apps/lightcraft/src/launch.rs b/apps/lightcraft/src/launch.rs index a7b8904d6..13edce0fd 100644 --- a/apps/lightcraft/src/launch.rs +++ b/apps/lightcraft/src/launch.rs @@ -3,7 +3,8 @@ use super::prefs::config_dir; pub(crate) const HELP: &str = "\ lightcraft — photo library + raw developer -USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) +USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported; a library's catalog.snap or + folder opens that library) OPTIONS: --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or @@ -30,17 +31,48 @@ ENVIRONMENT: /// The launch arguments' files and folders to import (absolute), and a notice for each one that /// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374). -pub(crate) fn launch_imports(args: &[String]) -> (Vec, Vec) { +pub(crate) fn launch_imports(args: &[String]) -> LaunchArgs { let cwd = std::env::current_dir().ok(); let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); launch_imports_in(args, cwd.as_deref(), home.as_deref()) } -pub(crate) fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec, Vec) { - use lightcraft_engine::import::{LaunchPath, launch_path}; - let (mut files, mut notices) = (Vec::new(), Vec::new()); +/// The library a launch argument names, after `--library` (`flag`) and `LIGHTCRAFT_LIBRARY` (`env`): +/// the library to pass on as the flag, and whether the argument chose it (it is then remembered). +pub(crate) fn launch_library_choice( + flag: Option, + env: Option<&std::path::PathBuf>, + argument: Option, +) -> (Option, bool) { + match (flag, env, argument) { + (Some(flag), _, _) => (Some(flag), false), + (None, None, Some(argument)) => (Some(argument), true), + _ => (None, false), + } +} + +/// What the launch arguments ask for: files and folders to import, a library to open (a library's `catalog.snap` +/// or folder, double-clicked in the file manager), and a notice for each argument +/// left alone. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct LaunchArgs { + pub(crate) files: Vec, + pub(crate) library: Option, + pub(crate) notices: Vec, +} + +pub(crate) fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> LaunchArgs { + use lightcraft_engine::import::{LaunchPath, launch_deadline, launch_path_until}; + let (mut files, mut notices, mut library) = (Vec::new(), Vec::new(), None); + // one deadline for every argument's library check: they all run before the window opens + let deadline = launch_deadline(); for a in args { - match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) { + match launch_path_until(a, cwd, home, lightcraft_engine::devices::dcim_in, deadline) { + Some(LaunchPath::Library(dir)) if library.is_none() => library = Some(std::path::PathBuf::from(dir)), + Some(LaunchPath::Library(dir)) => { + log::warn!("one library at a time: not opening {dir} given at launch"); + notices.push(format!("{dir} was not opened: LightCraft opens one library at a time. Open it with File ▸ Open Library…")); + } Some(LaunchPath::Import(p)) => files.push(p), Some(LaunchPath::Refused { path, why }) => { log::warn!("not importing {path} given at launch: {why}"); @@ -50,7 +82,7 @@ pub(crate) fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, None => {} } } - (files, notices) + LaunchArgs { files, library, notices } } /// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`). diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index dbc2b2044..c65af517d 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -55,7 +55,10 @@ use gpu_crash::{gpu_crash_check, gpu_lost_check_at, gpu_lost_marker_path, gpu_lo use gpu_crash::{gpu_crash_check_at, gpu_crash_notice, window_wgpu_options}; #[cfg(test)] use launch::launch_imports_in; -use launch::{HELP, control_port_from, launch_imports, log_dir, panic_log_path_with, startup_failed, stopped_text, stopped_while_running}; +use launch::{ + HELP, LaunchArgs, control_port_from, launch_imports, launch_library_choice, log_dir, panic_log_path_with, startup_failed, stopped_text, + stopped_while_running, +}; #[cfg(test)] use platform::{explorer_select_arg, explorer_select_command, windows_open_url_command}; use prefs::{PrefsWriter, as_remembered, load_prefs, prefs_path}; @@ -198,7 +201,11 @@ fn main() -> eframe::Result { } // what the arguments name, resolved now: an empty or relative argument never becomes a drive // root, and a whole drive or home folder is not walked (issue #374) - let (files, launch_notices) = launch_imports(&files); + let LaunchArgs { files, library: launch_library, notices: launch_notices } = launch_imports(&files); + // a library double-clicked (its catalog.snap or folder) is the one that opens, not the last one + // used; --library and LIGHTCRAFT_LIBRARY still win + let env_library: Option = std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()).map(Into::into); + let (library_dir, opened_by_argument) = launch_library_choice(library_dir, env_library.as_ref(), launch_library); let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory); // the saved language from the start, so a failed start is reported in it too if let Some(ui) = &prefs { @@ -209,7 +216,7 @@ fn main() -> eframe::Result { let launch = lightcraft_ui_egui::panels::library_problem::launch_plan( in_memory, library_dir, - std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()).map(Into::into), + env_library, prefs.as_ref().map(|u| &u.settings), lightcraft_engine::library::default_dir(), ); @@ -301,6 +308,20 @@ fn main() -> eframe::Result { keep_file: keep_prefs_file, ..Default::default() }; + // a library opened from its catalog file is the one in use from now on, as Open Library makes it + // (after the snapshot above, so the writer saves it) + if opened_by_argument + && problem.is_none() + && let Some(dir) = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy().into_owned()) + { + let settings = &mut app.ui.settings; + if settings.library_path != dir && !settings.library_path.is_empty() { + let previous = settings.library_path.clone(); + lightcraft_ui_egui::panels::settings::remember_library(&mut settings.recent_libraries, &previous); + } + lightcraft_ui_egui::panels::settings::remember_library(&mut settings.recent_libraries, &dir); + settings.library_path = dir; + } // after the snapshot above, so the writer saves the switched-off preference if let Some(notice) = gpu_crash { app.ui.settings.gpu = false; diff --git a/apps/lightcraft/src/tests.rs b/apps/lightcraft/src/tests.rs index 79b11904d..d9ec07e8d 100644 --- a/apps/lightcraft/src/tests.rs +++ b/apps/lightcraft/src/tests.rs @@ -14,17 +14,57 @@ fn launch_arguments_never_walk_a_drive_or_the_home_folder() { let home = dir("launch"); std::fs::create_dir_all(home.join("Trip")).unwrap(); let args: Vec = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect(); - let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home)); + let LaunchArgs { files, notices, library } = launch_imports_in(&args, Some(&home), Some(&home)); + assert_eq!(library, None); assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]); assert_eq!(notices.len(), 2, "{notices:?}"); assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}"); // the working folder is a drive root (a launcher's choice): `.` is never the whole drive let root = home.ancestors().last().unwrap().to_path_buf(); - let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None); + let LaunchArgs { files, notices, .. } = launch_imports_in(&[".".to_string()], Some(&root), None); assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}"); let _ = std::fs::remove_dir_all(&home); } +/// `--library` and `LIGHTCRAFT_LIBRARY` win over a library given as an argument, which is +/// then not remembered either. +#[test] +fn the_library_flag_and_variable_win_over_a_library_argument() { + use std::path::PathBuf; + let (flag, env, arg) = (PathBuf::from("flag"), PathBuf::from("env"), PathBuf::from("arg")); + assert_eq!(launch_library_choice(Some(flag.clone()), Some(&env), Some(arg.clone())), (Some(flag.clone()), false)); + assert_eq!(launch_library_choice(None, Some(&env), Some(arg.clone())), (None, false), "the variable is passed on as itself"); + assert_eq!(launch_library_choice(None, None, Some(arg.clone())), (Some(arg), true)); + assert_eq!(launch_library_choice(None, None, None), (None, false)); + assert_eq!(launch_library_choice(Some(flag.clone()), None, None), (Some(flag), false)); +} + +/// A library's catalog.snap (double-clicked in Explorer) or its folder given at +/// launch opens that library and is never imported as photos; a second library is left with a notice. +#[test] +fn a_librarys_catalog_given_at_launch_opens_that_library() { + let home = dir("launch-library"); + let lib = home.join("Wedding LC"); + let mut s = lightcraft_engine::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.close_library().unwrap(); + let snap = lib.join("catalog.snap"); + assert!(snap.is_file(), "a library keeps its catalog.snap"); + let other = home.join("Other LC"); + let mut s = lightcraft_engine::Session::new().with_fs(); + s.open_library(&other, false).unwrap(); + s.close_library().unwrap(); + for arg in [snap.to_string_lossy().to_string(), lib.to_string_lossy().to_string(), "Wedding LC/catalog.snap".to_string()] { + let got = launch_imports_in(std::slice::from_ref(&arg), Some(&home), Some(&home.join("elsewhere"))); + assert_eq!(got.library.as_deref(), Some(lib.as_path()), "{arg}"); + assert!(got.files.is_empty() && got.notices.is_empty(), "{arg}: never imported: {got:?}"); + } + let got = launch_imports_in(&[snap.to_string_lossy().to_string(), other.to_string_lossy().to_string()], Some(&home), None); + assert_eq!(got.library.as_deref(), Some(lib.as_path()), "the first one"); + assert_eq!(got.notices.len(), 1, "{got:?}"); + let _ = std::fs::remove_dir_all(&home); +} + /// Issue #236: the window answers control requests on its UI thread, so the desktop session /// adds to Apple Photos on a worker (headless sessions wait instead). #[test] diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 7ba00a310..00643221c 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -45,7 +45,9 @@ mod types; pub(crate) use commit::workers; pub use commit::{batch_size, civil, commit_prepared, system_clock}; pub use defaults::{CameraDefault, ImportDefaults, apply_import_defaults, has_import_look, import_defaults}; -pub use files::{EXTENSIONS, LaunchPath, accept_list, expand, expand_within, is_supported, launch_path}; +pub use files::{ + EXTENSIONS, LAUNCH_PROBE_LIMIT, LaunchPath, accept_list, expand, expand_within, is_supported, launch_deadline, launch_path, launch_path_until, +}; pub(crate) use files::{expand_noting, probe_all, probe_paths, probe_with}; #[cfg(test)] use job::PreparedItem; diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 9cbe22997..39c55f56f 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -121,6 +121,53 @@ pub enum LaunchPath { /// Not imported: `path` (absolute) is a whole drive or the whole home folder, which is never /// walked without being chosen in the app (issue #374). Refused { path: String, why: String }, + /// Open this (absolute) library folder: the argument was a library's `catalog.snap` or its folder, as a + /// double-click on a catalog in the file manager gives. Never imported. + Library(String), +} + +/// The library folder `path` names: a library's `catalog.snap` (its folder), or a folder holding one. `None` for +/// anything else (photos, folders of photos). The folder must pass the library opener's own test +/// ([`crate::library::is_library`]), so a `CATALOG.SNAP` the opener would not read (a case-sensitive file system) +/// never opens its folder as a new, empty library. +pub fn library_dir_of(path: &Path) -> Option { + let snapshot = lightcraft_catalog::journal::SNAPSHOT; + let named = path.file_name().is_some_and(|n| n.to_string_lossy().eq_ignore_ascii_case(snapshot)); + if named && path.is_file() { + return path.parent().filter(|dir| crate::library::is_library(dir)).map(Path::to_path_buf); + } + (path.is_dir() && crate::library::is_library(path)).then(|| path.to_path_buf()) +} + +/// How long the launch arguments' library checks may take, all of them together, before LightCraft gives up and +/// treats the arguments not yet checked as photos to import (in the background). A path on a dead network mount +/// can block a file-system query for minutes, and these checks run before the window opens. +pub const LAUNCH_PROBE_LIMIT: std::time::Duration = std::time::Duration::from_secs(2); + +/// When the launch arguments' library checks, starting now, must be done: [`LAUNCH_PROBE_LIMIT`] from now. +pub fn launch_deadline() -> std::time::Instant { + let now = std::time::Instant::now(); + now.checked_add(LAUNCH_PROBE_LIMIT).unwrap_or(now) +} + +/// `probe`'s answer, or `None` when it takes longer than `limit`: it runs on its own thread, which is left to +/// finish (or stay blocked) on its own. Also `None` when no thread can be started. +pub(crate) fn within(limit: std::time::Duration, probe: impl FnOnce() -> Option + Send + 'static) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + let started = std::thread::Builder::new().name("launch-probe".into()).spawn(move || { + let _ = tx.send(probe()); + }); + if let Err(e) = started { + log::warn!("could not start the launch-path check: {e}"); + return None; + } + match rx.recv_timeout(limit) { + Ok(found) => found, + Err(_) => { + log::warn!("the launch-path check took longer than {limit:?}; treating the argument as photos to import"); + None + } + } } /// Resolve a launch argument for importing (issue #374). An empty argument is ignored (`None`); @@ -130,6 +177,19 @@ pub enum LaunchPath { /// or file-system root is replaced by its camera folder (`DCIM`, from `dcim`: a memory card) or /// refused, and so is the home folder `home`: either would walk most of the disk. pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: impl Fn(&Path) -> Option) -> Option { + launch_path_until(arg, cwd, home, dcim, launch_deadline()) +} + +/// [`launch_path`], with its library check bounded by `deadline` (from [`launch_deadline`]), which every argument +/// of one launch shares: several paths on a dead mount wait [`LAUNCH_PROBE_LIMIT`] in all, not each. Once it has +/// passed, an argument is imported without the check. +pub fn launch_path_until( + arg: &str, + cwd: Option<&Path>, + home: Option<&Path>, + dcim: impl Fn(&Path) -> Option, + deadline: std::time::Instant, +) -> Option { if arg.trim().is_empty() { return None; } @@ -164,6 +224,13 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp if home.is_some_and(|h| key(&h.to_string_lossy()) == key(&shown)) { return Some(LaunchPath::Refused { path: shown, why: "the whole home folder".into() }); } + let left = deadline.saturating_duration_since(std::time::Instant::now()); + let probed = abs.clone(); + if !left.is_zero() + && let Some(dir) = within(left, move || library_dir_of(&probed)) + { + return Some(LaunchPath::Library(dir.to_string_lossy().to_string())); + } Some(LaunchPath::Import(shown)) } @@ -363,6 +430,67 @@ mod prepared_tests { use super::*; use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-launch-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// A launch argument names a library only when the library opener would read that folder: a `CATALOG.SNAP` + /// on a case-sensitive file system (no `catalog.snap` or `catalog.log` beside it) is not one. + #[test] + fn a_launch_argument_names_a_library_only_when_the_opener_can_read_it() { + let dir = temp_dir("upper"); + let upper = dir.join("CATALOG.SNAP"); + std::fs::write(&upper, b"x").unwrap(); + let found = library_dir_of(&upper); + assert_eq!(found.is_some(), crate::library::is_library(&dir), "{found:?}"); + if let Some(d) = &found { + assert!(crate::library::is_library(d), "{} opened as a library the opener can't read", d.display()); + } + // a folder holding only the journal is a library to the opener, so it is one here too + let journal = temp_dir("journal"); + std::fs::write(journal.join(lightcraft_catalog::journal::LOG), b"x").unwrap(); + assert_eq!(library_dir_of(&journal), Some(journal.clone())); + // a folder of photos is not + let photos = temp_dir("photos"); + std::fs::write(photos.join("a.jpg"), b"x").unwrap(); + assert_eq!(library_dir_of(&photos), None); + for d in [dir, journal, photos] { + let _ = std::fs::remove_dir_all(d); + } + } + + /// The library check runs before the window opens; one that blocks (a dead network mount) gives up after the + /// limit and the argument is imported instead, so LightCraft still starts. + #[test] + fn a_blocked_launch_probe_gives_up_after_its_limit() { + let start = std::time::Instant::now(); + let found = within(std::time::Duration::from_millis(50), || { + std::thread::sleep(std::time::Duration::from_secs(5)); + Some(PathBuf::from("late")) + }); + assert_eq!(found, None); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "waited {:?}", start.elapsed()); + assert_eq!(within(LAUNCH_PROBE_LIMIT, || Some(1)), Some(1), "a quick answer is kept"); + } + + /// The launch arguments share one deadline (CodeRabbit on #2979): once it has passed, an argument is imported + /// without its library check, so several paths on a dead mount can't each wait the limit. + #[test] + fn launch_arguments_share_one_deadline() { + let dir = temp_dir("deadline"); + std::fs::write(dir.join(lightcraft_catalog::journal::LOG), b"x").unwrap(); + let arg = dir.to_string_lossy().to_string(); + let none = |_: &Path| None; + assert_eq!(launch_path_until(&arg, None, None, none, launch_deadline()), Some(LaunchPath::Library(arg.clone()))); + let start = std::time::Instant::now(); + assert_eq!(launch_path_until(&arg, None, None, none, start), Some(LaunchPath::Import(arg.clone())), "deadline passed"); + assert!(start.elapsed() < std::time::Duration::from_millis(500), "waited {:?}", start.elapsed()); + let _ = std::fs::remove_dir_all(dir); + } + /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the /// files this build cannot import (see [`accept_list`]). fn pickable_extensions() -> Vec<&'static str> { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 73f7f8836..37222ff4a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -701,7 +701,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-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `cmd:file.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `crates/ui-egui/src/dialogs/lightroom_destination.rs`, `crates/ui-egui/src/tests/lightroom_destination.rs`, `docs/lightroom-catalog-import.md` | before the import starts, File ▸ Import Lightroom Catalog… asks where the catalog goes: merge into the open library (named, with its photo count; preselected when it is empty) or create a new library (named after the catalog file, next to the open library, both editable), which is made and opened following File ▸ New Library…'s rules before the import; Cancel does nothing; Settings ▸ General ▸ "When importing a Lightroom catalog" (Ask where to import it / Always merge into the open library) or the window's "Don't ask again" turns it off; `file.importLightroom` takes `into` (`open` / `new`), `name`, `location` to choose without the window; a session in memory merges without asking (issue #2672); read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders; VIDEO records match the Videos filter, with reimport repairing older image classification while keeping edits and memberships; a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | 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 (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); 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`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | -| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/dialogs/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests/launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests/keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290) | +| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/dialogs/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests/launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests/keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290); a library's `catalog.snap` or folder given at launch (a double-click in the file manager) opens that library, through the usual launch path (lock check), makes it the library in use and is never imported; a second library argument gets a notice | | 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.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests/panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; 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; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). 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…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, which is kept only once the move has started, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and 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); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged | | 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 +) | @@ -800,6 +800,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-29 | minor | LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket, with sharper raw fallback and the available camera JPEG retained when rendering cannot help (#2237, part of #2056); import loupe remains open; counts unchanged | +| 2026-10-25 | minor | LRC-LIB-CATALOG-SWITCH: a library's `catalog.snap` or folder given at launch (double-clicked in Explorer or Finder) opens that library instead of the last one, and is never imported as photos; status unchanged (✅) | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-25 | patch | LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence reservation; exhausted later files are reported failed while earlier transfers remain accounted for; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) |