From 457c8991605837a17c349d6428cbe0c363547db2 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 20:48:36 +1100 Subject: [PATCH 1/3] Library: a library's own file names its folder; any other file is a clear error `lightcraft-cli run --library /catalog.snap` failed with the OS's "File exists" (creating a folder over the file). Session::open_library now opens the folder that holds catalog.snap / catalog.log / catalog.lock, and says "is a file; a library is a folder" for any other file. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/library.rs | 60 +++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4c8bf28aa..683c2b50c 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -32,6 +32,32 @@ use crate::{EngineError, LibrarySource, Result, Selection, Session}; /// Library directory name inside the user's Pictures folder. pub const DEFAULT_NAME: &str = "LightCraft Library"; +/// The library folder `path` names. A library is a folder; naming one of its own files +/// (`catalog.snap`, `catalog.log`, `catalog.lock`) means the folder that holds it. Any other +/// existing file is an error that says so, instead of the OS's "File exists" from creating the +/// folder. +pub fn resolve_dir(path: &Path) -> Result { + if !path.is_file() { + return Ok(path.to_path_buf()); + } + let own = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG, lightcraft_catalog::lock::LOCK].contains(&n)); + if !own { + return Err(EngineError::Other(format!("{} is a file; a library is a folder", path.display()))); + } + Ok(match path.parent().filter(|p| !p.as_os_str().is_empty()) { + Some(dir) => dir.to_path_buf(), + None => PathBuf::from("."), + }) +} + +/// Whether `dir` already holds a library (its catalog files). +pub fn is_library(dir: &Path) -> bool { + dir.join(lightcraft_catalog::journal::SNAPSHOT).is_file() || dir.join(lightcraft_catalog::journal::LOG).is_file() +} + /// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library` /// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows). pub fn default_dir() -> Option { @@ -253,7 +279,7 @@ impl Session { /// has it open, this fails with [`EngineError::LibraryInUse`] and nothing is read or changed. /// Reopening the library this session already has open keeps its lock. pub fn open_library(&mut self, dir: impl AsRef, seed_demo: bool) -> Result<&LoadReport> { - let dir = dir.as_ref().to_path_buf(); + let dir = resolve_dir(dir.as_ref())?; let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display()))); let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() }; // the library open in this session (same directory): hand its lock over @@ -655,3 +681,35 @@ fn unlocked_warning(lock: Option<&LibraryLock>) -> Option { .to_string() }) } + +#[cfg(test)] +mod resolve_tests { + use super::*; + + /// `--library /catalog.snap` failed with the OS's "File exists" (creating a folder over + /// the file); it opens the library that file belongs to. + #[test] + fn a_librarys_own_file_opens_its_folder_and_any_other_file_is_a_clear_error() { + let dir = std::env::temp_dir().join(format!("lc-resolve-lib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&dir, false).unwrap(); + s.close_library().unwrap(); + drop(s); + assert!(is_library(&dir)); + let snap = dir.join(lightcraft_catalog::journal::SNAPSHOT); + assert_eq!(resolve_dir(&snap).unwrap(), dir); + let mut s = Session::new().with_fs(); + s.open_library(&snap, false).unwrap(); + assert_eq!(s.library.as_ref().map(|l| l.dir.clone()), Some(dir.clone())); + drop(s); + + let other = dir.join("notes.txt"); + std::fs::write(&other, b"x").unwrap(); + let e = Session::new().with_fs().open_library(&other, false).err().unwrap().to_string(); + assert!(e.contains("is a file"), "{e}"); + assert!(!e.contains("File exists"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} From 75cf78175a84b420eec342f4d29340573e142163 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 20:48:36 +1100 Subject: [PATCH 2/3] =?UTF-8?q?Library=20switching:=20New=20Library?= =?UTF-8?q?=E2=80=A6,=20Open=20Recent=20Library,=20library=20name=20in=20t?= =?UTF-8?q?he=20title=20(#902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.newLibrary {path?, name?}: create a library (in a chosen location, or a given folder) and switch to it; refuses an existing library, a non-empty folder, a file or a bad name. - File ▸ Open Recent Library: the last 8 libraries (app settings), missing ones greyed out; opening a gone one is an error that forgets it instead of creating an empty library. app.recentLibraries / app.clearRecentLibraries for CLI and MCP. - The window title names the open library; Settings ▸ General gains New Library…. - A library that can't be opened leaves the current one open. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/locales/de.json | 8 +- crates/ui-egui/locales/es.json | 8 +- crates/ui-egui/locales/fr.json | 8 +- crates/ui-egui/locales/ja.json | 8 +- crates/ui-egui/locales/pt-br.json | 8 +- crates/ui-egui/locales/ru.json | 8 +- crates/ui-egui/locales/uk.json | 8 +- crates/ui-egui/locales/zh-hans.json | 8 +- crates/ui-egui/locales/zh-hant.json | 8 +- crates/ui-egui/src/lib.rs | 27 +++++ crates/ui-egui/src/menubar.rs | 31 +++++- crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/panels/settings.rs | 124 ++++++++++++++++++++- crates/ui-egui/src/state.rs | 7 ++ crates/ui-egui/src/tests_switch_library.rs | 100 +++++++++++++++++ 15 files changed, 357 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 6520fb3e3..5bd19f53a 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2039,5 +2039,11 @@ "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", - "Looking for changes": "Änderungen werden gesucht" + "Looking for changes": "Änderungen werden gesucht", + "New Library…": "Neue Bibliothek…", + "Open Recent Library": "Zuletzt verwendete Bibliothek öffnen", + "Recent Libraries": "Zuletzt verwendete Bibliotheken", + "Clear Recent Libraries": "Liste leeren", + "No Recent Libraries": "Keine zuletzt verwendeten Bibliotheken", + "New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index fc53b6392..018941773 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1884,5 +1884,11 @@ "Downloading model": "Descargando modelo", "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", - "Looking for changes": "Buscando cambios" + "Looking for changes": "Buscando cambios", + "New Library…": "Nueva biblioteca…", + "Open Recent Library": "Abrir biblioteca reciente", + "Recent Libraries": "Bibliotecas recientes", + "Clear Recent Libraries": "Borrar bibliotecas recientes", + "No Recent Libraries": "No hay bibliotecas recientes", + "New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 53509a503..6763ae52e 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1884,5 +1884,11 @@ "Panels": "Panels", "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels" + "Resize panels": "Resize panels", + "New Library…": "Nouvelle bibliothèque…", + "Open Recent Library": "Ouvrir une bibliothèque récente", + "Recent Libraries": "Bibliothèques récentes", + "Clear Recent Libraries": "Effacer les bibliothèques récentes", + "No Recent Libraries": "Aucune bibliothèque récente", + "New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2babd30b6..65316dbfb 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1886,5 +1886,11 @@ "Downloading model": "モデルをダウンロード中", "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", - "Looking for changes": "変更を確認中" + "Looking for changes": "変更を確認中", + "New Library…": "新しいライブラリ…", + "Open Recent Library": "最近使ったライブラリを開く", + "Recent Libraries": "最近使ったライブラリ", + "Clear Recent Libraries": "最近使ったライブラリを消去", + "No Recent Libraries": "最近使ったライブラリはありません", + "New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index dfe472818..2c7fd3080 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1884,5 +1884,11 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "New Library…": "Nova biblioteca…", + "Open Recent Library": "Abrir biblioteca recente", + "Recent Libraries": "Bibliotecas recentes", + "Clear Recent Libraries": "Limpar bibliotecas recentes", + "No Recent Libraries": "Nenhuma biblioteca recente", + "New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fafb65a14..c07102fde 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1598,5 +1598,11 @@ "Finding faces": "Поиск лиц", "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", - "Looking for changes": "Поиск изменений" + "Looking for changes": "Поиск изменений", + "New Library…": "Новая библиотека…", + "Open Recent Library": "Открыть недавнюю библиотеку", + "Recent Libraries": "Недавние библиотеки", + "Clear Recent Libraries": "Очистить список недавних библиотек", + "No Recent Libraries": "Нет недавних библиотек", + "New Library: Choose Where to Put It": "Новая библиотека: выберите расположение" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 7ebad5b10..c5a2a25b7 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2078,5 +2078,11 @@ "Panels": "Panels", "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels" + "Resize panels": "Resize panels", + "New Library…": "Нова бібліотека…", + "Open Recent Library": "Відкрити нещодавню бібліотеку", + "Recent Libraries": "Нещодавні бібліотеки", + "Clear Recent Libraries": "Очистити список нещодавніх бібліотек", + "No Recent Libraries": "Немає нещодавніх бібліотек", + "New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 8a0e58b8e..e2c06bb4d 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1884,5 +1884,11 @@ "Downloading model": "正在下载模型", "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", - "Looking for changes": "正在查找更改" + "Looking for changes": "正在查找更改", + "New Library…": "新建图库…", + "Open Recent Library": "打开最近的图库", + "Recent Libraries": "最近的图库", + "Clear Recent Libraries": "清除最近的图库", + "No Recent Libraries": "没有最近的图库", + "New Library: Choose Where to Put It": "新建图库:选择存放位置" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f1cbec3a0..965bafb28 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1884,5 +1884,11 @@ "Downloading model": "正在下載模型", "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", - "Looking for changes": "正在尋找變更" + "Looking for changes": "正在尋找變更", + "New Library…": "新增圖庫…", + "Open Recent Library": "開啟最近的圖庫", + "Recent Libraries": "最近的圖庫", + "Clear Recent Libraries": "清除最近的圖庫", + "No Recent Libraries": "沒有最近的圖庫", + "New Library: Choose Where to Put It": "新增圖庫:選擇存放位置" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f853fe166..d4742bbba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -301,6 +301,8 @@ pub struct LightcraftApp { pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. pub lightroom_last: Option, + /// The library generation the window title and the recent list were last updated for. + pub shown_library: Option, /// A background export in progress. pub export: Option, /// Background file-system work of other commands (Find Missing Photos, auto import…). @@ -380,6 +382,7 @@ impl LightcraftApp { sync_run: None, lightroom: None, lightroom_last: None, + shown_library: None, export: None, tasks: Default::default(), last_export_result: None, @@ -1009,6 +1012,29 @@ impl LightcraftApp { self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); } + /// Once per library opened (at launch too): name it in the window title, so nobody edits in + /// the wrong one, and put it first in File ▸ Open Recent Library. + pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { + let generation = self.session.library_generation(); + if self.shown_library == Some(generation) { + return; + } + self.shown_library = Some(generation); + if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { + let path = l.dir.to_string_lossy().into_owned(); + panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); + } + + /// "LightCraft — " (just "LightCraft" for a library that isn't saved). + pub fn window_title(&self) -> String { + match panels::settings::open_library_name(self) { + Some(name) => format!("LightCraft — {name}"), + None => "LightCraft".to_string(), + } + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { i18n::set_language(self.ui.language); @@ -1021,6 +1047,7 @@ impl LightcraftApp { if std::mem::take(&mut self.ui.quit) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + self.library_changed(&ctx); // panels set it again this frame while the pointer rests on a preset or profile self.hover_preview = None; self.ai_mask_detail(&ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b919aa2e6..97d86c961 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -45,7 +45,10 @@ impl MenuNode { /// User-defined names in parameterized menus are data, not message keys. pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str { - if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) { + if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") + || (id == "app.export" && params.get("preset").is_some()) + || (id == "app.openLibrary" && params.get("path").is_some()) + { label } else { crate::i18n::tr(label) @@ -66,7 +69,9 @@ const LAYOUT: &[(&str, &[&str])] = &[ "file.addFolder", "@Import from Device", "---", + "app.newLibrary", "app.openLibrary", + "@Open Recent Library", "file.backupLibrary", "file.restoreLibrary", "---", @@ -511,6 +516,30 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } + "Open Recent Library" => { + let can = !crate::lightroom_import::is_running(app); + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let mut v: Vec = app + .ui + .settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + // a missing library (moved, drive disconnected) is shown greyed out + let label = + format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()); + item("app.openLibrary", json!({"path": r}), label, None, can && d.is_dir(), Some(open.as_deref() == Some(d))) + }) + .collect(); + if v.is_empty() { + v.push(item("app.clearRecentLibraries", Value::Null, "No Recent Libraries", None, false, None)); + } else { + v.push(MenuNode::Separator); + v.push(item("app.clearRecentLibraries", Value::Null, "Clear Recent Libraries", None, true, None)); + } + v + } "Import from Device" => { let devices = lightcraft_engine::devices::devices(); if devices.is_empty() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81a46eb98..7812ffee8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -179,7 +179,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), + ("app.newLibrary", "New Library…", None, "File"), ("app.openLibrary", "Open Library…", None, "File"), + ("app.recentLibraries", "Recent Libraries", None, ""), + ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), // Settings ▸ Display ("app.displayProfile", "Display Profile", None, ""), ("app.about", "About LightCraft", None, "Help"), @@ -491,6 +494,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::settings::open_library(app, p), + "app.newLibrary" => crate::panels::settings::new_library(app, p), + "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), + "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), "app.displayProfile" => crate::panels::settings::display_profile(app, p), "file.backupLibrary" | "file.restoreLibrary" => { let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; @@ -1619,10 +1625,11 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, - "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), + "app.openLibrary" | "app.newLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), "file.backupLibrary" => app.services.backup_library.is_some(), "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), + "app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(), "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 7036e10f1..f2bbb3971 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -122,6 +122,11 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if r.clicked() { let _ = app.run("app.openLibrary", json!({})); } + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…"))); + register(ui.ctx(), "button:settingsNewLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.newLibrary", json!({})); + } if !can { hint(ui, t, crate::i18n::tr("not available here")); } @@ -642,13 +647,128 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result } } }; + // a remembered library that is gone: say so (and forget it) instead of creating an empty one there + if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() { + app.ui.settings.recent_libraries.retain(|r| r != &path); + return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library")); + } + switch_library(app, &path) +} + +/// Close the current library and open the one at `path`. If it can't be opened (locked by +/// another program, unreadable, a file), the error is returned and the current library stays open. +fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { + let dir = lightcraft_engine::library::resolve_dir(std::path::Path::new(path)).map_err(|e| e.to_string())?; + let path = dir.to_string_lossy().into_owned(); app.session.close_library().map_err(|e| e.to_string())?; - app.session.open_library(&path, false).map_err(|e| e.to_string())?; + app.session.open_library(&dir, false).map_err(|e| format!("{path}: {e}"))?; app.lightroom_last = None; app.renderer.forget_all(); app.ui.compare = None; app.ui.settings.library_path = path.clone(); - Ok(json!({"path": path, "photos": app.session.catalog.len()})) + remember_library(&mut app.ui.settings.recent_libraries, &path); + Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) +} + +/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`]. +pub fn remember_library(recent: &mut Vec, path: &str) { + recent.retain(|r| r != path); + recent.insert(0, path.to_string()); + recent.truncate(crate::state::RECENT_LIBRARIES); +} + +/// A library's name as people see it: its folder's name. +pub fn library_name(dir: &std::path::Path) -> String { + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) +} + +/// The open library's name (`None`: in memory, nothing is saved). +pub fn open_library_name(app: &LightcraftApp) -> Option { + app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir)) +} + +/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library +/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it, +/// `path` itself, or a new folder named after the default library. The folder must not hold a +/// library already, nor anything else. +pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result { + if crate::lightroom_import::is_running(app) { + return Err("wait for Lightroom catalog import to finish before switching libraries".into()); + } + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(valid_library_name(s)?), + Some(_) => return Err("app.newLibrary: name must be a string".into()), + }; + let (location, picked) = match p.get("path").and_then(Value::as_str) { + Some(x) => (std::path::PathBuf::from(x), false), + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It")); + match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => (std::path::PathBuf::from(x), true), + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } + }; + let dir = match name { + Some(n) => location.join(n), + // a chosen location gets a new folder in it, named after the default library + None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME), + None => location, + }; + if lightcraft_engine::library::is_library(&dir) { + return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display())); + } + if dir.is_file() { + return Err(format!("{}: is a file; a library is a folder", dir.display())); + } + if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { + return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); + } + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?; + switch_library(app, &dir.to_string_lossy()) +} + +fn valid_library_name(s: &str) -> Result<&str, String> { + let n = s.trim(); + if n.is_empty() || n == "." || n == ".." || n.contains(['/', '\\']) || n.chars().any(char::is_control) { + return Err(format!("app.newLibrary: {s:?} can't be a library name (a folder name, without / or \\)")); + } + Ok(n) +} + +/// `base` inside `location`, or `base 2`, `base 3`… if that is taken. +fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { + let first = location.join(base); + if !first.exists() { + return first; + } + (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// `app.recentLibraries {clear?: bool}`: the recently opened libraries, newest first, as +/// `[{path, name, exists, open}]` (`exists: false`: moved or on a disconnected drive). `clear` +/// forgets them all. +pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { + if p.get("clear").and_then(Value::as_bool) == Some(true) { + app.ui.settings.recent_libraries.clear(); + } + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let list: Vec = app + .ui + .settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d)}) + }) + .collect(); + Ok(Value::Array(list)) } // ---------------------------------------------------------------------------- Display profile diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6a50e2baf..5891df20b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,9 @@ impl InfoOverlay { } } +/// How many libraries File ▸ Open Recent Library remembers. +pub const RECENT_LIBRARIES: usize = 8; + /// App-level preferences (Settings dialog). They belong to the app, not a library, and are /// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library /// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. @@ -135,6 +138,9 @@ impl InfoOverlay { pub struct AppSettings { /// Library opened at launch when no `--library` is given (empty = the default location). pub library_path: String, + /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most + /// [`RECENT_LIBRARIES`]). + pub recent_libraries: Vec, pub startup_view: StartupView, /// Ask before moving photos to Recently Deleted (keyboard and menu). pub confirm_delete: bool, @@ -165,6 +171,7 @@ impl Default for AppSettings { fn default() -> Self { AppSettings { library_path: String::new(), + recent_libraries: Vec::new(), startup_view: StartupView::Last, confirm_delete: false, gpu: true, diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index d7bffc0d8..90b9e1ebb 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -41,3 +41,103 @@ fn opening_another_library_drops_the_old_librarys_cached_panels() { let _ = std::fs::remove_dir_all(&dir); } + +fn temp(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn app_in(dir: &std::path::Path) -> LightcraftApp { + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + LightcraftApp::new(session, Services::default()) +} + +fn recent_submenu(app: &LightcraftApp) -> Vec { + use crate::menubar::MenuNode; + let bar = crate::menubar::menu_bar(app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + match file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Open Recent Library")) { + Some(MenuNode::Submenu { children, .. }) => children.clone(), + _ => panic!("no Open Recent Library submenu in {file:?}"), + } +} + +/// Issue #902: File ▸ New Library… creates a named library and opens it; it shows in the window +/// title and in Open Recent Library, newest first. +#[test] +fn new_library_creates_opens_and_is_remembered_and_named() { + let dir = temp("newlib"); + let mut app = app_in(&dir); + app.library_changed(&egui::Context::default()); + assert_eq!(app.window_title(), "LightCraft — first"); + + let r = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap(); + assert_eq!(r["name"], "Client A"); + let a = dir.join("Client A"); + assert_eq!(app.session.library.as_ref().unwrap().dir, a); + assert!(lightcraft_engine::library::is_library(&a), "the catalog is written"); + assert_eq!(app.window_title(), "LightCraft — Client A"); + assert_eq!(app.ui.settings.library_path, a.to_string_lossy()); + + // a second New Library at the same place is refused and the current one stays open + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap_err(); + assert!(e.contains("library here already"), "{e}"); + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "../x"})).unwrap_err(); + assert!(e.contains("library name"), "{e}"); + assert_eq!(app.session.library.as_ref().unwrap().dir, a); + + let list = app.run("app.recentLibraries", json!({})).unwrap(); + let names: Vec<_> = list.as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(names, ["Client A", "first"]); + assert_eq!(list[0]["open"], true); + assert_eq!(list[1]["exists"], true); + + // Open Recent Library reopens "first" + let items = recent_submenu(&app); + let first = items + .iter() + .find_map(|n| match n { + crate::menubar::MenuNode::Item { id, params, enabled, .. } + if id == "app.openLibrary" && params["path"].as_str().is_some_and(|p| p.ends_with("first")) => + { + assert!(*enabled); + Some(params.clone()) + } + _ => None, + }) + .expect("first in Open Recent Library"); + app.run("app.openLibrary", first).unwrap(); + assert_eq!(app.window_title(), "LightCraft — first"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A remembered library that's gone is greyed out in the menu; opening it anyway is an error that +/// keeps the current library open and drops it from the list. The list is capped. +#[test] +fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() { + let dir = temp("recentlib"); + let mut app = app_in(&dir); + let gone = dir.join("gone").to_string_lossy().into_owned(); + app.ui.settings.recent_libraries = vec![gone.clone()]; + let disabled = recent_submenu(&app) + .iter() + .any(|n| matches!(n, crate::menubar::MenuNode::Item { params, enabled: false, .. } if params["path"] == gone.as_str())); + assert!(disabled, "missing library greyed out"); + let e = app.run("app.openLibrary", json!({"path": gone})).unwrap_err(); + assert!(e.contains("no longer there"), "{e}"); + assert!(!std::path::Path::new(&gone).exists(), "no empty library created in its place"); + assert!(app.ui.settings.recent_libraries.is_empty()); + assert!(app.session.library.as_ref().unwrap().dir.ends_with("first")); + + for i in 0..20 { + crate::panels::settings::remember_library(&mut app.ui.settings.recent_libraries, &format!("/x/{i}")); + } + assert_eq!(app.ui.settings.recent_libraries.len(), crate::state::RECENT_LIBRARIES); + assert_eq!(app.ui.settings.recent_libraries[0], "/x/19"); + app.run("app.clearRecentLibraries", json!({})).unwrap(); + assert!(app.ui.settings.recent_libraries.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} From d94d6856e9c0056d387e0e8b6ceef75f9401fc1f Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 21:08:24 +1100 Subject: [PATCH 3/3] Library switching: name prompt, library name under My Photos, docs (#902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Library… from the menu asks for a name (default prefilled), then the location. - The open library's name shows under My Photos in the left panel (hover: its path), so it is visible on macOS too, where the window title isn't. - Switching also remembers the library being left (e.g. the one opened at launch). - whats-new entry and parity row LRC-LIB-CATALOG-SWITCH. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/locales/de.json | 4 +++- crates/ui-egui/locales/es.json | 4 +++- crates/ui-egui/locales/fr.json | 4 +++- crates/ui-egui/locales/ja.json | 4 +++- crates/ui-egui/locales/pt-br.json | 4 +++- crates/ui-egui/locales/ru.json | 4 +++- crates/ui-egui/locales/uk.json | 4 +++- crates/ui-egui/locales/zh-hans.json | 4 +++- crates/ui-egui/locales/zh-hant.json | 4 +++- crates/ui-egui/src/panels/left.rs | 20 ++++++++++++++++++-- crates/ui-egui/src/panels/settings.rs | 18 ++++++++++++++++++ crates/ui-egui/src/tests_switch_library.rs | 21 +++++++++++++++++++++ docs/parity-checklist.md | 7 ++++--- docs/whats-new.md | 11 +++++++++++ 14 files changed, 99 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 5bd19f53a..fa2d96f7c 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2045,5 +2045,7 @@ "Recent Libraries": "Zuletzt verwendete Bibliotheken", "Clear Recent Libraries": "Liste leeren", "No Recent Libraries": "Keine zuletzt verwendeten Bibliotheken", - "New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen" + "New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen", + "New Library": "Neue Bibliothek", + "Library name": "Name der Bibliothek" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 018941773..d3f30836a 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1890,5 +1890,7 @@ "Recent Libraries": "Bibliotecas recientes", "Clear Recent Libraries": "Borrar bibliotecas recientes", "No Recent Libraries": "No hay bibliotecas recientes", - "New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla" + "New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla", + "New Library": "Nueva biblioteca", + "Library name": "Nombre de la biblioteca" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 6763ae52e..0512baae9 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1890,5 +1890,7 @@ "Recent Libraries": "Bibliothèques récentes", "Clear Recent Libraries": "Effacer les bibliothèques récentes", "No Recent Libraries": "Aucune bibliothèque récente", - "New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement" + "New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement", + "New Library": "Nouvelle bibliothèque", + "Library name": "Nom de la bibliothèque" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 65316dbfb..d5f83ab2e 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1892,5 +1892,7 @@ "Recent Libraries": "最近使ったライブラリ", "Clear Recent Libraries": "最近使ったライブラリを消去", "No Recent Libraries": "最近使ったライブラリはありません", - "New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択" + "New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択", + "New Library": "新しいライブラリ", + "Library name": "ライブラリ名" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2c7fd3080..89b404e12 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1890,5 +1890,7 @@ "Recent Libraries": "Bibliotecas recentes", "Clear Recent Libraries": "Limpar bibliotecas recentes", "No Recent Libraries": "Nenhuma biblioteca recente", - "New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la" + "New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la", + "New Library": "Nova biblioteca", + "Library name": "Nome da biblioteca" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c07102fde..97f690170 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1604,5 +1604,7 @@ "Recent Libraries": "Недавние библиотеки", "Clear Recent Libraries": "Очистить список недавних библиотек", "No Recent Libraries": "Нет недавних библиотек", - "New Library: Choose Where to Put It": "Новая библиотека: выберите расположение" + "New Library: Choose Where to Put It": "Новая библиотека: выберите расположение", + "New Library": "Новая библиотека", + "Library name": "Название библиотеки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c5a2a25b7..8e5501afb 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2084,5 +2084,7 @@ "Recent Libraries": "Нещодавні бібліотеки", "Clear Recent Libraries": "Очистити список нещодавніх бібліотек", "No Recent Libraries": "Немає нещодавніх бібліотек", - "New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування" + "New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування", + "New Library": "Нова бібліотека", + "Library name": "Назва бібліотеки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index e2c06bb4d..953a50fee 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1890,5 +1890,7 @@ "Recent Libraries": "最近的图库", "Clear Recent Libraries": "清除最近的图库", "No Recent Libraries": "没有最近的图库", - "New Library: Choose Where to Put It": "新建图库:选择存放位置" + "New Library: Choose Where to Put It": "新建图库:选择存放位置", + "New Library": "新建图库", + "Library name": "图库名称" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 965bafb28..5131bd0c5 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1890,5 +1890,7 @@ "Recent Libraries": "最近的圖庫", "Clear Recent Libraries": "清除最近的圖庫", "No Recent Libraries": "沒有最近的圖庫", - "New Library: Choose Where to Put It": "新增圖庫:選擇存放位置" + "New Library: Choose Where to Put It": "新增圖庫:選擇存放位置", + "New Library": "新增圖庫", + "Library name": "圖庫名稱" } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 4f1bae44c..65541f680 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -182,8 +182,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |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, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); + let (hr, header) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); + // the open library's name under the title, so nobody edits in the wrong one (issue #902) + let library = crate::panels::settings::open_library_name(app); + let title_y = if library.is_some() { hr.center().y - 7.0 } else { hr.center().y }; + ui.painter().text(pos2(hr.left() + 18.0, title_y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); + if let Some(name) = library { + ui.painter().with_clip_rect(hr.shrink2(vec2(18.0, 0.0))).text( + pos2(hr.left() + 18.0, hr.center().y + 10.0), + Align2::LEFT_CENTER, + name, + t.font(11.0), + t.text_dim, + ); + crate::widgets::register(ui.ctx(), "label:libraryName", hr); + if let Some(l) = app.session.library.as_ref() { + header.on_hover_text(l.dir.display().to_string()); + } + } let counts = app.caches.counts(&app.session.catalog); let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f2bbb3971..6be7cac21 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -660,12 +660,17 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { let dir = lightcraft_engine::library::resolve_dir(std::path::Path::new(path)).map_err(|e| e.to_string())?; let path = dir.to_string_lossy().into_owned(); + // the library being left goes in the list too (also when it was opened at launch) + let previous = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy().into_owned()); app.session.close_library().map_err(|e| e.to_string())?; app.session.open_library(&dir, false).map_err(|e| format!("{path}: {e}"))?; app.lightroom_last = None; app.renderer.forget_all(); app.ui.compare = None; app.ui.settings.library_path = path.clone(); + if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { + remember_library(&mut app.ui.settings.recent_libraries, &prev); + } remember_library(&mut app.ui.settings.recent_libraries, &path); Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) } @@ -700,6 +705,19 @@ pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result Some(Value::String(s)) => Some(valid_library_name(s)?), Some(_) => return Err("app.newLibrary: name must be a string".into()), }; + // from the menu: ask for the name first (default prefilled), then for the location + if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() { + crate::panels::dialogs::prompt( + app, + "New Library", + "Library name", + lightcraft_engine::library::DEFAULT_NAME, + "app.newLibrary", + json!({}), + "name", + ); + return Ok(Value::Null); + } let (location, picked) = match p.get("path").and_then(Value::as_str) { Some(x) => (std::path::PathBuf::from(x), false), None => { diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 90b9e1ebb..d45b4ea14 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -114,6 +114,27 @@ fn new_library_creates_opens_and_is_remembered_and_named() { let _ = std::fs::remove_dir_all(&dir); } +/// From the menu, New Library… asks for a name (default prefilled), then where to put it. +#[test] +fn new_library_from_the_menu_asks_for_a_name_then_a_location() { + let dir = temp("newlibprompt"); + let mut app = app_in(&dir); + let loc = dir.to_string_lossy().into_owned(); + app.services.pick_folder = Some(Box::new(move || Some(loc.clone()))); + app.run("app.newLibrary", json!({})).unwrap(); + let Some(crate::state::Dialog::TextPrompt { value, command, key, .. }) = app.ui.dialog.as_mut() else { + panic!("no name prompt: {:?}", app.ui.dialog) + }; + assert_eq!(value, lightcraft_engine::library::DEFAULT_NAME); + assert_eq!((command.as_str(), key.as_str()), ("app.newLibrary", "name")); + *value = "Wedding".into(); + // what the dialog's OK runs + app.ui.dialog = None; + app.run("app.newLibrary", json!({"name": "Wedding"})).unwrap(); + assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Wedding")); + let _ = std::fs::remove_dir_all(&dir); +} + /// A remembered library that's gone is greyed out in the menu; opening it anyway is an error that /// keeps the current library open and drops it from the list. The list is capped. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..804c781fd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) | -| **Total** | 397 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 34 | 15 | 35 | 9 | — | 24/26 (92%) | +| **Total** | 398 | 49 | 73 | 36 | 192/200 (96%) | 144/156 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.2% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 520 in-scope rows — P0 98.0% of 200 · P1 95.2% of 156 · P2 47.2% of 163. ## Top gaps and shortcut notes @@ -681,6 +681,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`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; 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, 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). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | 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`) | +| 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/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | | 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.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: 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…, 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; `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); creating a folder and moving photos between folders are not here; 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 isn't cancelled midway 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 +) | diff --git a/docs/whats-new.md b/docs/whats-new.md index f3fb3a143..4668f5599 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,16 @@ # What's new in LightCraft +## 2026-10-11 + +### Switching libraries +- File ▸ New Library… asks for a name, then where to put it, and opens the new library (issue #902). +- File ▸ Open Recent Library lists the last 8 libraries; ones that are gone (moved, drive disconnected) are greyed + out. Clear Recent Libraries empties the list. +- The open library's name shows under My Photos at the top of the left panel and in the window title. +- `lightcraft-cli --library` given a library's own file (`catalog.snap`) opens its folder instead of failing with + "File exists". +- Agents use `app.newLibrary {path, name}`, `app.recentLibraries` and `app.clearRecentLibraries`. + ## 2026-10-10 ### Activity stack