Merge branch 'fix/issue-100-open-failure' into integrate/safety
This commit is contained in:
commit
4deb5eff04
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <port>` (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.
|
||||
|
|
@ -218,15 +221,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<std::path::PathBuf>, 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<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
|
||||
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();
|
||||
|
|
@ -240,11 +247,11 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, 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())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -321,7 +328,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;
|
||||
|
|
@ -331,7 +338,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
/// The memory budget setting last applied (MB, 0 = automatic).
|
||||
memory_applied: Option<u32>,
|
||||
/// 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<panels::library_problem::LibraryProblem>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<String>, error: impl Into<String>) -> 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<String>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -81,6 +81,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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue