From 75cf78175a84b420eec342f4d29340573e142163 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 20:48:36 +1100 Subject: [PATCH] =?UTF-8?q?Library=20switching:=20New=20Library=E2=80=A6,?= =?UTF-8?q?=20Open=20Recent=20Library,=20library=20name=20in=20the=20title?= =?UTF-8?q?=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); +}