From 5e618c553a9ef28ba56c0432709007c482219dae Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 17:48:25 -0400 Subject: [PATCH] Issue #100: Ask what to do when the library can't be opened, never a silent demo session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When open_library failed, the desktop app (and the web build) quietly ran an in-memory session seeded with the demo photos: it looked like a reset library and everything done in it was lost at quit. - The session now starts empty and in memory (no demo photos) and LightcraftApp::library_problem shows a blocking window with the path and the error: Try Again, Choose Another Library… (app.openLibrary), Continue Without Saving, Quit. Command-line files are imported once the user has chosen. - A temporary session shows a banner under the top bar (with Open Library…) for as long as it lasts; it has no library, so nothing is written anywhere. Opening a library ends it. - ui.inspect → libraryProblem; widget ids for automation; documented in control-protocol.md. - Web build: same window (Try Again disabled: reloading the page retries). - Headless UI test: locked library → window → Continue Without Saving → banner, nothing written to the library → lock released → Try Again opens it. Checked in the real app over the control channel too. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/web.rs | 14 +- apps/lightcraft/src/main.rs | 35 ++-- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 10 + crates/ui-egui/src/panels/library_problem.rs | 182 +++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/tests_library_problem.rs | 101 ++++++++++ docs/control-protocol.md | 9 + docs/parity.md | 2 +- docs/whats-new.md | 4 + 10 files changed, 344 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/panels/library_problem.rs create mode 100644 crates/ui-egui/src/tests_library_problem.rs diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 09a19595..56b2868b 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -295,6 +295,7 @@ impl WebApp { files: Box::new(files.store()), on_disk: false, }; + let mut problem = None; match session.open_library_in(stores, true).cloned() { Ok(r) => log::info!( "lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)", @@ -305,9 +306,17 @@ impl WebApp { r.replayed ), Err(e) => { - log::error!("opening the library failed: {e}; starting a temporary one"); - session = Session::with_demo(); + // never a silent demo session (issue #100): an empty one, and the window says why + log::error!("opening the library failed: {e}"); + session = Session::new(); originals.install(&mut session); + problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem { + can_retry: false, + ..lightcraft_ui_egui::panels::library_problem::LibraryProblem::new( + "the browser's storage for this page", + format!("{e}. Reload the page to try again."), + ) + }); } } // previews are large (≤ 2560 px, f32): keep few in a 32-bit address space @@ -318,6 +327,7 @@ impl WebApp { app.ui = ui; } app.ui = app.ui.sanitized(); + app.library_problem = problem; let n = opts.workers.unwrap_or_else(|| { let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize); cores.saturating_sub(1).clamp(1, 4) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 981867c8..ebbc68d0 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -7,7 +7,9 @@ //! `~/Pictures/LightCraft Library`; a new library starts with the //! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command //! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes -//! nothing (demo photos unless files are given; used by the README showcase scripts). +//! nothing (demo photos unless files are given; used by the README showcase scripts). A library +//! that can't be opened is never replaced silently: the window says why and asks what to do +//! (`lightcraft_ui_egui::panels::library_problem`). //! //! `--control ` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. @@ -26,6 +28,7 @@ mod control_server; mod native_menu; use lightcraft_engine::Session; +use lightcraft_ui_egui::panels::library_problem::LibraryProblem; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; /// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name. @@ -227,15 +230,19 @@ fn services() -> Services { } } -/// The persistent library session (or an in-memory one with `--memory` / if the library can't open). -fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> Session { - let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(); +/// The persistent library session, or with `--memory` an in-memory one (demo photos). +/// +/// If the library can't be opened the session is empty and in memory — never seeded with the +/// demo photos, never written anywhere — and the problem is returned: the window then says so +/// and offers Try Again / Choose Another Library… / Continue Without Saving / Quit (issue #100). +fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { if in_memory { - return fallback(); + return (if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock(), None); } + let unopened = || Session::new().with_fs().with_system_clock(); let Some(dir) = dir else { - eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory"); - return fallback(); + eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)"); + return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it."))); }; let t0 = std::time::Instant::now(); let mut s = Session::new().with_fs().with_system_clock(); @@ -249,11 +256,11 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo if torn > 0 { ", torn tail repaired" } else { "" }, t0.elapsed().as_secs_f64() * 1000.0 ); - s + (s, None) } Err(e) => { - eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display()); - fallback() + eprintln!("lightcraft: can't open library {}: {e}", dir.display()); + (unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string()))) } } } @@ -330,7 +337,7 @@ fn main() -> eframe::Result { "LightCraft", options, Box::new(move |cc| { - let session = open_session(in_memory, library_dir, seed_demo && files.is_empty()); + let (session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); let mut app = LightcraftApp::new(session, services()); if let Some(ui) = prefs { app.ui = ui; @@ -340,7 +347,11 @@ fn main() -> eframe::Result { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); } - if !files.is_empty() { + if let Some(mut p) = problem { + // imported once the user has chosen where (into the library, or the temporary session) + p.pending_import = files; + app.library_problem = Some(p); + } else if !files.is_empty() { let _ = app.run("library.import", serde_json::json!({"paths": files})); app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e71124de..d655c953 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -83,6 +83,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, "unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})), + "libraryProblem": app.library_problem.as_ref().map(crate::panels::library_problem::LibraryProblem::to_json), "scan": app.scan.as_ref().map(crate::import::ScanTask::status), "export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result}, "memory": memory(app), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec4cbc3b..6700fbde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,8 @@ mod tests_curve; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_library_problem; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_panels; @@ -170,6 +172,9 @@ pub struct LightcraftApp { gpu_applied: Option, /// The memory budget setting last applied (MB, 0 = automatic). memory_applied: Option, + /// The library failed to open at launch: the blocking window, then the temporary-session + /// banner (issue #100). Cleared once a library opens. + pub library_problem: Option, } impl LightcraftApp { @@ -213,6 +218,7 @@ impl LightcraftApp { window_is_fullscreen: false, gpu_applied: None, memory_applied: None, + library_problem: None, } } @@ -440,6 +446,7 @@ impl LightcraftApp { /// Per-frame logic before layout (control channel, renders, shortcuts, drops). pub fn logic(&mut self, ctx: &egui::Context) { let t0 = now_ms(); + panels::library_problem::logic(self); self.logic_inner(ctx); self.perf.logic_ms = now_ms() - t0; } @@ -602,6 +609,7 @@ impl LightcraftApp { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); panels::second::show(self, &ctx); panels::dialogs::show(self, &ctx); + panels::library_problem::show(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.end_frame(t0); @@ -610,6 +618,7 @@ impl LightcraftApp { // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, // right panels and left panel run to the bottom; the bottom bar sits between them). panels::topbar::show(self, ui); + panels::library_problem::banner(self, ui); panels::strip::show(self, ui); if self.ui.right != state::RightPanel::None { panels::right::show(self, ui); @@ -637,6 +646,7 @@ impl LightcraftApp { }); panels::second::show(self, &ctx); panels::dialogs::show(self, &ctx); + panels::library_problem::show(self, &ctx); import::progress(self, &ctx); import::scan_progress(self, &ctx); export_task::poll(self, &ctx); diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs new file mode 100644 index 00000000..a8a97148 --- /dev/null +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -0,0 +1,182 @@ +//! The library couldn't be opened at launch (issue #100): never a silent in-memory session. A +//! blocking window says which library and why, and offers Try Again, Choose Another Library…, +//! Continue Without Saving and Quit. Continuing shows a banner under the top bar for as long as the +//! session is temporary. + +use egui::{Align2, RichText, vec2}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A library that failed to open (set by the host at launch). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LibraryProblem { + /// The library folder ("" when there was none to try, e.g. no home folder). + pub path: String, + /// Why it didn't open. + pub error: String, + /// Continue Without Saving was chosen: the banner shows instead of the window. + pub dismissed: bool, + /// Files from the command line, imported once a library opens. + pub pending_import: Vec, + /// Try Again reopens `path` as a library folder (`false` in the browser, whose storage isn't + /// a folder: reloading the page tries again). + pub can_retry: bool, +} + +impl LibraryProblem { + pub fn new(path: impl Into, error: impl Into) -> LibraryProblem { + LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() } + } + + /// `ui.inspect` → `libraryProblem`. + pub fn to_json(&self) -> serde_json::Value { + json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len()}) + } +} + +/// What the user chose. +enum Choice { + Retry, + Choose, + Temporary, + Quit, +} + +/// Open `path` (`None`: ask for a folder) through `app.openLibrary`; on success the problem is +/// over (and command-line files are imported), else it shows the new error. +fn open(app: &mut LightcraftApp, path: Option) { + let params = match &path { + Some(p) => json!({"path": p}), + None => json!({}), + }; + match app.run("app.openLibrary", params) { + Ok(serde_json::Value::Null) => {} // folder dialog cancelled + Ok(_) => { + let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default(); + if !files.is_empty() { + let _ = app.run("library.import", json!({"paths": files})); + } + } + Err(e) => { + if let Some(p) = app.library_problem.as_mut() { + if let Some(path) = path { + p.path = path; + } + p.error = e; + p.dismissed = false; + } + } + } +} + +/// Clear the problem once a library is open (e.g. through Settings → Open Library…). +pub fn logic(app: &mut LightcraftApp) { + if app.library_problem.is_some() && app.session.library.is_some() { + app.library_problem = None; + } +} + +/// The blocking window (until a choice is made). +pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(problem) = app.library_problem.clone() else { return }; + if problem.dismissed { + return; + } + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { + // swallows clicks: nothing behind the window can be used meanwhile + ui.allocate_rect(screen, egui::Sense::click_and_drag()); + ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); + }); + let mut choice = None; + let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); + egui::Window::new("Your library couldn't be opened") + .id(egui::Id::new("library-problem")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .frame(frame) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .default_width(460.0) + .show(ctx, |ui| { + // a fixed width: a wrapped label in an auto-sized, centred window would move it every frame + ui.set_width(440.0); + ui.spacing_mut().item_spacing.y = 8.0; + if problem.path.is_empty() { + ui.label(RichText::new("LightCraft couldn't find where to keep your library.").color(t.text)); + } else { + ui.label(RichText::new("LightCraft couldn't open the library at").color(t.text_label)); + ui.label(RichText::new(&problem.path).font(t.semibold(12.5)).color(t.text)); + } + ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap()); + ui.label( + RichText::new( + "Your photos and edits in it are untouched. You can try again (e.g. after closing another program that has it open \ + or reconnecting its drive), open a different library or create a new one, or continue without a library — \ + then nothing you do is saved.", + ) + .color(t.text_dim), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { + let r = ui.add_enabled(enabled, egui::Button::new(label).min_size(vec2(0.0, 26.0))); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.clicked() { + choice = Some(c); + } + }; + button(ui, "libraryRetry", "Try Again", problem.can_retry && !problem.path.is_empty(), Choice::Retry); + button(ui, "libraryChoose", "Choose Another Library…", app.services.pick_folder.is_some(), Choice::Choose); + button(ui, "libraryTemporary", "Continue Without Saving", true, Choice::Temporary); + button(ui, "libraryQuit", "Quit", true, Choice::Quit); + }); + }); + match choice { + Some(Choice::Retry) => open(app, Some(problem.path)), + Some(Choice::Choose) => open(app, None), + Some(Choice::Temporary) => { + if let Some(p) = app.library_problem.as_mut() { + p.dismissed = true; + } + let files = app.library_problem.as_mut().map(|p| std::mem::take(&mut p.pending_import)).unwrap_or_default(); + if !files.is_empty() { + let _ = app.run("library.import", json!({"paths": files})); + } + } + Some(Choice::Quit) => app.ui.quit = true, + None => {} + } +} + +/// The banner under the top bar while the session is temporary (Continue Without Saving). +pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { + if !app.library_problem.as_ref().is_some_and(|p| p.dismissed) { + return; + } + let t = Tokens::get(ui.ctx()); + let mut reopen = false; + egui::Panel::top("library-problem-banner") + .exact_size(30.0) + .frame(egui::Frame::NONE.fill(t.caution).inner_margin(egui::Margin::symmetric(12, 0))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + let r = ui.label( + RichText::new("Temporary session — your library isn't open, and nothing you do here is saved.") + .font(t.semibold(12.5)) + .color(t.canvas), + ); + register(ui.ctx(), "indicator:temporarySession", r.rect); + let b = ui.add(egui::Button::new(RichText::new("Open Library…").color(t.canvas)).fill(egui::Color32::from_black_alpha(40))); + register(ui.ctx(), "button:libraryReopen", b.rect); + reopen = b.clicked(); + }); + }); + if reopen && let Some(p) = app.library_problem.as_mut() { + p.dismissed = false; + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 726dc599..8634ee34 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -10,6 +10,7 @@ pub mod edit; pub mod filterbar; pub mod grid; pub mod left; +pub mod library_problem; pub mod masking; pub mod presets; pub mod profiles; diff --git a/crates/ui-egui/src/tests_library_problem.rs b/crates/ui-egui/src/tests_library_problem.rs new file mode 100644 index 00000000..b6b79604 --- /dev/null +++ b/crates/ui-egui/src/tests_library_problem.rs @@ -0,0 +1,101 @@ +//! Issue #100: a library that can't be opened at launch is never replaced by a silent in-memory +//! demo session. The window says why and offers Try Again / Choose Another Library… / Continue +//! Without Saving / Quit; a temporary session shows a banner and never writes to the library. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::library_problem::LibraryProblem; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +#[test] +fn unopenable_library_asks_instead_of_running_a_demo() { + let dir = std::env::temp_dir().join(format!("lc-ui-libproblem-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let lib = dir.join("lib"); + std::fs::create_dir_all(&lib).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let photo = dir.join("a.png"); + std::fs::write(&photo, png).unwrap(); + + // another program has the library open: the launch fails like the desktop host's would + let held = lightcraft_catalog::LibraryLock::acquire(&lib, "another LightCraft").unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + let err = session.open_library(&lib, true).unwrap_err().to_string(); + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + let mut app = LightcraftApp::new( + session, + Services { + png: Some(Box::new(png)), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }, + ); + app.library_problem = + Some(LibraryProblem { pending_import: vec![photo.to_string_lossy().to_string()], ..LibraryProblem::new(lib.to_string_lossy(), err) }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(h.app.session.catalog.is_empty(), "no demo photos"); + for b in ["button:libraryRetry", "button:libraryChoose", "button:libraryTemporary", "button:libraryQuit"] { + assert!(has(&h, b), "{b}"); + } + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("dialog.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + let inspect = h.request("ui.inspect", json!({}), T); + let p = &inspect["result"]["libraryProblem"]; + assert!(p["error"].as_str().unwrap().contains("already open in another LightCraft"), "{p}"); + assert_eq!(p["temporarySession"], false); + + // still locked: Try Again keeps the window, with the error + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + h.step(); + assert!(h.app.session.library.is_none()); + assert!(has(&h, "button:libraryRetry")); + + // Continue Without Saving: a banner, the command-line photo in the temporary session, and + // nothing written into the library folder + let before: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryTemporary"}), T)["ok"], true); + h.step(); + h.step(); + assert!(!has(&h, "button:libraryRetry")); + assert!(has(&h, "indicator:temporarySession")); + assert!(h.app.session.library.is_none()); + assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the temporary session"); + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": std::path::Path::new(&p).join("banner.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 3}}), T); + let after: Vec<_> = std::fs::read_dir(&lib).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(before, after, "the temporary session never writes to the library"); + + // the other program quits; Open Library… → Try Again opens it, the banner goes away + drop(held); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryReopen"}), T)["ok"], true); + h.step(); + assert!(has(&h, "button:libraryRetry")); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryRetry"}), T)["ok"], true); + h.step(); + h.step(); + assert!(h.app.session.library.is_some()); + assert!(h.app.library_problem.is_none()); + assert!(!has(&h, "indicator:temporarySession") && !has(&h, "button:libraryRetry")); + assert!(h.app.session.catalog.is_empty(), "an opened (new) library is never seeded with demo photos here"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cade7ecc..e84314e9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,6 +49,15 @@ shows a warning (widget `indicator:unsaved`). Queries and commands that change n compaction (snapshot) is not a failed command — the log is kept whole — and only shows in `library.info` → `lastError`. +### When the library can't be opened + +If the desktop app can't open its library at launch (another program has it open, an unreadable or newer-format +catalog, a missing drive), the session starts empty and in memory — never with demo photos — and a window asks what +to do: `ui.inspect` → `libraryProblem` is `{path, error, temporarySession, pendingImport}` (else `null`); its buttons +are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Continue Without Saving) and +`button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and +writes nothing. `app.openLibrary` opening a library ends it. + ## Headless rendering (no window, no GPU) The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by diff --git a/docs/parity.md b/docs/parity.md index 16f83ea8..736b49e2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -713,7 +713,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning; no read-only mode; the browser build (OPFS) has no guard across tabs yet | +| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning; no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; like the file manager, not an undo step) | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 14f5c477..17b51c4c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -42,6 +42,10 @@ has open — on this computer or another one sharing the folder — says who has it ("already open in LightCraft (process 123 on studio-mac)") instead of letting both write and silently drop each other's edits. A crash never leaves the library locked: the lock is the operating system's and goes away with the program. +- If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected, + written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue + Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and + lost everything at quit; a temporary session shows a banner the whole time and never writes to your library. ### Editing - Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.