# Conflicts:
#	crates/engine/src/library.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/settings.rs
#	crates/ui-egui/src/state.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Claude
2026-10-11 12:01:18 +00:00
20 changed files with 501 additions and 21 deletions
+59
View File
@@ -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<PathBuf> {
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<PathBuf> {
@@ -262,6 +288,7 @@ impl Session {
if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) {
return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog });
}
let dir = resolve_dir(&dir)?;
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
@@ -663,3 +690,35 @@ fn unlocked_warning(lock: Option<&LibraryLock>) -> Option<String> {
.to_string()
})
}
#[cfg(test)]
mod resolve_tests {
use super::*;
/// `--library <dir>/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);
}
}
+1 -6
View File
@@ -56,11 +56,6 @@ fn is_lrcat_name(name: &str) -> bool {
name.to_lowercase().ends_with(".lrcat")
}
/// A LightCraft library lives in `dir` (its catalog journal is there).
pub fn is_lightcraft_library(dir: &Path) -> bool {
[lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG].iter().any(|f| dir.join(f).is_file())
}
/// The first `*.lrcat` file (by name) at the top level of `dir`, looking at no more than
/// [`SCAN_LIMIT`] entries. Unreadable folders and entries are skipped.
pub fn find_lrcat(dir: &Path) -> Option<PathBuf> {
@@ -93,7 +88,7 @@ fn catalog_or_scan(candidate: Option<PathBuf>, dir: Option<&Path>) -> Option<Pat
/// the module docs). `None`: not Lightroom's, open it as a library.
pub fn lightroom_catalog_at(path: &Path) -> Option<LightroomPlace> {
let is_dir = path.is_dir();
if is_dir && is_lightcraft_library(path) {
if is_dir && crate::library::is_library(path) {
return None;
}
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
+9 -1
View File
@@ -2066,5 +2066,13 @@
"Content-Aware": "Inhaltsbasiert",
"imported earlier switch over with Photo ▸ Reload from Disk.": "Bereits importierte Fotos wechseln mit Foto → Vom Datenträger neu laden zur RAW-Bearbeitung.",
"Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "C-RAW-Dateien der Canon EOS R5 Mark II, die als „Nur Vorschau“ geöffnet wurden (je nach Motiv mehr als die Hälfte),",
"scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "werden jetzt wie die übrigen RAW-Dateien der Kamera aus ihren Sensordaten mit allen Werkzeugen bearbeitet."
"scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "werden jetzt wie die übrigen RAW-Dateien der Kamera aus ihren Sensordaten mit allen Werkzeugen bearbeitet.",
"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",
"New Library": "Neue Bibliothek",
"Library name": "Name der Bibliothek"
}
+9 -1
View File
@@ -1908,5 +1908,13 @@
"Perceptual": "Perceptual",
"Relative": "Relativo",
"Simulate Paper & Ink": "Simular papel y tinta",
"Content-Aware": "Según el contenido"
"Content-Aware": "Según el contenido",
"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",
"New Library": "Nueva biblioteca",
"Library name": "Nombre de la biblioteca"
}
+9 -1
View File
@@ -1908,5 +1908,13 @@
"Perceptual": "Perceptif",
"Relative": "Relatif",
"Simulate Paper & Ink": "Simuler le papier et l'encre",
"Content-Aware": "Contenu pris en compte"
"Content-Aware": "Contenu pris en compte",
"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",
"New Library": "Nouvelle bibliothèque",
"Library name": "Nom de la bibliothèque"
}
+9 -1
View File
@@ -1910,5 +1910,13 @@
"Perceptual": "知覚的",
"Relative": "相対的",
"Simulate Paper & Ink": "紙とインキをシミュレート",
"Content-Aware": "コンテンツに応じる"
"Content-Aware": "コンテンツに応じる",
"New Library…": "新しいライブラリ…",
"Open Recent Library": "最近使ったライブラリを開く",
"Recent Libraries": "最近使ったライブラリ",
"Clear Recent Libraries": "最近使ったライブラリを消去",
"No Recent Libraries": "最近使ったライブラリはありません",
"New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択",
"New Library": "新しいライブラリ",
"Library name": "ライブラリ名"
}
+9 -1
View File
@@ -1908,5 +1908,13 @@
"Perceptual": "Perceptual",
"Relative": "Relativo",
"Simulate Paper & Ink": "Simular papel e tinta",
"Content-Aware": "Sensível ao conteúdo"
"Content-Aware": "Sensível ao conteúdo",
"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",
"New Library": "Nova biblioteca",
"Library name": "Nome da biblioteca"
}
+9 -1
View File
@@ -1622,5 +1622,13 @@
"Perceptual": "Перцепционный",
"Relative": "Относительный",
"Simulate Paper & Ink": "Имитировать бумагу и краску",
"Content-Aware": "С учётом содержимого"
"Content-Aware": "С учётом содержимого",
"New Library…": "Новая библиотека…",
"Open Recent Library": "Открыть недавнюю библиотеку",
"Recent Libraries": "Недавние библиотеки",
"Clear Recent Libraries": "Очистить список недавних библиотек",
"No Recent Libraries": "Нет недавних библиотек",
"New Library: Choose Where to Put It": "Новая библиотека: выберите расположение",
"New Library": "Новая библиотека",
"Library name": "Название библиотеки"
}
+9 -1
View File
@@ -2102,5 +2102,13 @@
"Perceptual": "Перцепційний",
"Relative": "Відносний",
"Simulate Paper & Ink": "Імітувати папір і фарбу",
"Content-Aware": "З урахуванням вмісту"
"Content-Aware": "З урахуванням вмісту",
"New Library…": "Нова бібліотека…",
"Open Recent Library": "Відкрити нещодавню бібліотеку",
"Recent Libraries": "Нещодавні бібліотеки",
"Clear Recent Libraries": "Очистити список нещодавніх бібліотек",
"No Recent Libraries": "Немає нещодавніх бібліотек",
"New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування",
"New Library": "Нова бібліотека",
"Library name": "Назва бібліотеки"
}
+9 -1
View File
@@ -1908,5 +1908,13 @@
"Perceptual": "可感知",
"Relative": "相对",
"Simulate Paper & Ink": "模拟纸张和油墨",
"Content-Aware": "内容识别"
"Content-Aware": "内容识别",
"New Library…": "新建图库…",
"Open Recent Library": "打开最近的图库",
"Recent Libraries": "最近的图库",
"Clear Recent Libraries": "清除最近的图库",
"No Recent Libraries": "没有最近的图库",
"New Library: Choose Where to Put It": "新建图库:选择存放位置",
"New Library": "新建图库",
"Library name": "图库名称"
}
+9 -1
View File
@@ -1908,5 +1908,13 @@
"Perceptual": "感知",
"Relative": "相對",
"Simulate Paper & Ink": "模擬紙張和油墨",
"Content-Aware": "內容感知"
"Content-Aware": "內容感知",
"New Library…": "新增圖庫…",
"Open Recent Library": "開啟最近的圖庫",
"Recent Libraries": "最近的圖庫",
"Clear Recent Libraries": "清除最近的圖庫",
"No Recent Libraries": "沒有最近的圖庫",
"New Library: Choose Where to Put It": "新增圖庫:選擇存放位置",
"New Library": "新增圖庫",
"Library name": "圖庫名稱"
}
+27
View File
@@ -334,6 +334,8 @@ pub struct LightcraftApp {
pub lightroom: Option<lightroom_import::LightroomTask>,
/// Last terminal Lightroom result, exposed by the command's status/wait response.
pub lightroom_last: Option<Value>,
/// The library generation the window title and the recent list were last updated for.
pub shown_library: Option<u64>,
/// A background export in progress.
pub export: Option<export_task::ExportTask>,
/// Background file-system work of other commands (Find Missing Photos, auto import…).
@@ -418,6 +420,7 @@ impl LightcraftApp {
sync_run: None,
lightroom: None,
lightroom_last: None,
shown_library: None,
export: None,
tasks: Default::default(),
last_export_result: None,
@@ -1098,6 +1101,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 — <library name>" (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);
@@ -1111,6 +1137,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);
+30 -1
View File
@@ -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",
"---",
@@ -513,6 +518,30 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
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<MenuNode> = 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() {
+8 -1
View File
@@ -181,7 +181,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"),
@@ -513,6 +516,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"app.openLibrary" => 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() };
@@ -1676,10 +1682,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" | "compare.set" => 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()
+18 -2
View File
@@ -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| {
+140 -2
View File
@@ -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"));
}
@@ -659,13 +664,146 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() });
return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path}));
}
// 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<Value, String> {
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(&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()}))
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()}))
}
/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`].
pub fn remember_library(recent: &mut Vec<String>, 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<String> {
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<Value, String> {
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()),
};
// 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 => {
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<Value, String> {
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<Value> = 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
+6
View File
@@ -158,6 +158,8 @@ impl LightsOut {
}
}
}
/// 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
@@ -167,6 +169,9 @@ impl LightsOut {
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<String>,
pub startup_view: StartupView,
/// Ask before moving photos to Recently Deleted (keyboard and menu).
pub confirm_delete: bool,
@@ -197,6 +202,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,
+121
View File
@@ -41,3 +41,124 @@ 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<crate::menubar::MenuNode> {
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);
}
/// 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]
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);
}
File diff suppressed because one or more lines are too long
+9
View File
@@ -13,6 +13,15 @@
scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos
imported earlier switch over with Photo ▸ Reload from Disk.
### 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
### Dialogs fit the window