mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -06:00
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:
co-authored by
Claude Opus 5.5
parent
457c899160
commit
75cf78175a
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "新しいライブラリ:保存場所を選択"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Новая библиотека: выберите расположение"
|
||||
}
|
||||
|
||||
@@ -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": "Нова бібліотека: виберіть розташування"
|
||||
}
|
||||
|
||||
@@ -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": "新建图库:选择存放位置"
|
||||
}
|
||||
|
||||
@@ -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": "新增圖庫:選擇存放位置"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user