Library switching: New Library…, Open Recent Library, library name in the title (#902)

- 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) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-11 20:48:36 +11:00
co-authored by Claude Opus 5.5
parent 457c899160
commit 75cf78175a
15 changed files with 357 additions and 13 deletions
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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": "新しいライブラリ:保存場所を選択"
}
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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": "Новая библиотека: выберите расположение"
}
+7 -1
View File
@@ -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": "Нова бібліотека: виберіть розташування"
}
+7 -1
View File
@@ -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": "新建图库:选择存放位置"
}
+7 -1
View File
@@ -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": "新增圖庫:選擇存放位置"
}
+27
View File
@@ -301,6 +301,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…).
@@ -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 — <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);
@@ -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);
+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",
"---",
@@ -511,6 +516,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
@@ -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<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() };
@@ -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()
+122 -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"));
}
@@ -642,13 +647,128 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
}
}
};
// 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();
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<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()),
};
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
+7
View File
@@ -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<String>,
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,
+100
View File
@@ -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<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);
}
/// 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);
}