#1290: list the recent libraries read-only

The launch window no longer probes each recent library with the lock check
of File > Open Library (which created catalog.lock and wrote
catalog.lock.owner into every free library at every launch, including
other drives, network shares and sleeping disks). A row is now checked by
looking only: the path exists, is a folder, can be listed, holds a
LightCraft catalog, and that catalog opens for reading. A library another
program has open is found when Open is pressed: the open fails with the
lock owner's message, the window stays and greys the row out.

New test: listing (and drawing the window) leaves every library folder
byte-identical (contents and modification times); it fails with the old
probe. Docs (What's New, parity row, control protocol) say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
This commit is contained in:
Claude
2026-10-23 11:37:04 +00:00
parent 2c70336fc3
commit 3fe02631e6
7 changed files with 108 additions and 40 deletions
+2 -2
View File
@@ -982,7 +982,7 @@ fn main() -> eframe::Result {
}
// the recent libraries to choose from (issue #1290), each checked now
if launch == Launch::Ask {
problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings));
problem = Some(LibraryProblem::choose(&app.ui.settings));
}
app.alt_asks_for_library = matches!(launch, Launch::Open { alt_asks: true, .. });
lightcraft_ui_egui::i18n::set_language(app.ui.language);
@@ -1177,7 +1177,7 @@ mod tests {
let path = d.join("ui.json");
let mut app = LightcraftApp::new(s, Services::default());
app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()];
app.library_problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings));
app.library_problem = Some(LibraryProblem::choose(&app.ui.settings));
let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
app.ui.settings.recent_libraries.remove(0);
w.save(&app).unwrap();
+4 -1
View File
@@ -2355,5 +2355,8 @@
"Open now": "Ouverte en ce moment",
"Last opened": "Ouverte le",
"Not found: moved, renamed or on a disconnected drive": "Introuvable : déplacée, renommée ou sur un disque déconnecté",
"There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft"
"There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft",
"This is a file, not a library folder": "C’est un fichier, pas un dossier de bibliothèque",
"This folder can't be read:": "Ce dossier ne peut pas être lu :",
"The library's catalog can't be read:": "Le catalogue de la bibliothèque ne peut pas être lu :"
}
+24 -15
View File
@@ -98,21 +98,17 @@ impl LibraryProblem {
LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() }
}
/// The window listing the recent libraries, each checked now: one that is gone, isn't a
/// LightCraft library any more, or can't be opened (another program has it, unreadable) is
/// greyed out with the reason. The last library opened is selected, or else the first one
/// The window listing the recent libraries, each checked now without writing anything
/// ([`unavailable`]): one that is gone, isn't a LightCraft library any more or can't be read
/// is greyed out with the reason. The last library opened is selected, or else the first one
/// that can be opened.
pub fn choose(session: &lightcraft_engine::Session, settings: &AppSettings) -> LibraryProblem {
pub fn choose(settings: &AppSettings) -> LibraryProblem {
// as many as File ▸ Open Recent Library keeps (a hand-edited settings file may hold more)
let libraries: Vec<RecentLibrary> = settings
.recent_libraries
.iter()
.take(crate::state::RECENT_LIBRARIES)
.map(|path| RecentLibrary {
path: path.clone(),
opened: settings.recent_opened.get(path).cloned(),
unavailable: unavailable(session, path),
})
.map(|path| RecentLibrary { path: path.clone(), opened: settings.recent_opened.get(path).cloned(), unavailable: unavailable(path) })
.collect();
let can = |l: &RecentLibrary| l.unavailable.is_none();
let selected = libraries
@@ -143,20 +139,33 @@ impl LibraryProblem {
}
}
/// Why the library at `path` can't be opened now (`None`: it can).
fn unavailable(session: &lightcraft_engine::Session, path: &str) -> Option<String> {
/// Why the library at `path` can't be opened (`None`: as far as can be told without opening it).
///
/// Read-only: the folder and its catalog file are looked at, never locked or written (the list
/// may name libraries on other drives, network shares or sleeping disks). A library another
/// program has open is therefore found when Open is pressed: the open fails and the window says
/// who has it.
fn unavailable(path: &str) -> Option<String> {
let dir = Path::new(path);
if !dir.exists() {
return Some(crate::i18n::tr("Not found: moved, renamed or on a disconnected drive").to_string());
}
// a folder that is there, readable and not held by another program
if let Err(e) = session.check_library(dir) {
return Some(e.to_string());
if !dir.is_dir() {
return Some(crate::i18n::tr("This is a file, not a library folder").to_string());
}
if let Err(e) = std::fs::read_dir(dir) {
return Some(format!("{} {e}", crate::i18n::tr("This folder can't be read:")));
}
// opening would make a new, empty library in it
if !lightcraft_engine::library::is_library(dir) {
return Some(crate::i18n::tr("There is no LightCraft library in this folder any more").to_string());
}
// the catalog the open reads first: the snapshot, else the log alone (opened to read, not read)
let snapshot = dir.join(lightcraft_catalog::journal::SNAPSHOT);
let catalog = if snapshot.is_file() { snapshot } else { dir.join(lightcraft_catalog::journal::LOG) };
if let Err(e) = std::fs::File::open(&catalog) {
return Some(format!("{} {e}", crate::i18n::tr("The library's catalog can't be read:")));
}
None
}
@@ -257,7 +266,7 @@ fn alt_at_launch(app: &mut LightcraftApp, ctx: &egui::Context) {
if crate::panels::settings::can_switch_library(app).is_err() {
return;
}
let mut p = LibraryProblem::choose(&app.session, &app.ui.settings);
let mut p = LibraryProblem::choose(&app.ui.settings);
if let Some(c) = p.chooser.as_mut() {
c.over = app.session.library.as_ref().map(|_| app.session.library_generation());
}
+70 -18
View File
@@ -84,48 +84,100 @@ fn the_list_checks_each_library_and_selects_the_last_one() {
let (a, b, held_path) = (make_library(&a), make_library(&b), make_library(&held));
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("notes.txt"), b"not a library").unwrap();
let gone = dir.join("Gone").to_string_lossy().into_owned();
let plain = plain.to_string_lossy().into_owned();
let file = dir.join("a-file.txt");
std::fs::write(&file, b"a file").unwrap();
let (gone, plain, file) =
(dir.join("Gone").to_string_lossy().into_owned(), plain.to_string_lossy().into_owned(), file.to_string_lossy().into_owned());
// another program has it open: listing doesn't take locks, so it is found when opened
let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap();
let session = lightcraft_engine::Session::new().with_fs();
let mut settings = AppSettings {
recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), b.clone()],
recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), file.clone(), b.clone()],
library_path: b.clone(),
..AppSettings::default()
};
settings.recent_opened.insert(b.clone(), "2026-10-20T08:30:00".into());
let p = LibraryProblem::choose(&session, &settings);
let p = LibraryProblem::choose(&settings);
assert!(!p.saves_nothing(), "the launch list is not a temporary session: removals are saved");
let c = p.chooser.as_ref().unwrap();
let why: Vec<Option<&str>> = c.libraries.iter().map(|l| l.unavailable.as_deref()).collect();
assert!(why[0].unwrap().contains("Not found"), "{why:?}");
assert!(why[1].unwrap().contains("already open in another LightCraft"), "{why:?}");
assert_eq!(why[1], None, "a lock is not probed: {why:?}");
assert_eq!(why[2], None);
assert!(why[3].unwrap().contains("no LightCraft library"), "{why:?}");
assert_eq!(why[4], None);
assert_eq!(c.selected, Some(4), "the last library opened");
assert_eq!(c.libraries[4].opened.as_deref(), Some("2026-10-20T08:30:00"));
assert!(why[4].unwrap().contains("not a library folder"), "{why:?}");
assert_eq!(why[5], None);
assert_eq!(c.selected, Some(5), "the last library opened");
assert_eq!(c.libraries[5].opened.as_deref(), Some("2026-10-20T08:30:00"));
assert_eq!(c.libraries[2].opened, None, "no time known");
let json = p.to_json();
assert_eq!(json["chooser"]["libraries"][1]["available"], false);
assert_eq!(json["chooser"]["selected"], 4);
assert_eq!(json["chooser"]["libraries"][0]["available"], false);
assert_eq!(json["chooser"]["selected"], 5);
// the last library can't be opened: the first one that can is selected
settings.library_path = held_path.clone();
assert_eq!(LibraryProblem::choose(&session, &settings).chooser.unwrap().selected, Some(2));
settings.library_path = plain.clone();
assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(1));
// none can: the first row, Open greyed out
settings.recent_libraries = vec![gone.clone()];
assert_eq!(LibraryProblem::choose(&session, &settings).chooser.unwrap().selected, Some(0));
settings.recent_libraries = vec![gone.clone(), file.clone()];
assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(0));
// an empty list selects nothing
settings.recent_libraries.clear();
let c = LibraryProblem::choose(&session, &settings).chooser.unwrap();
let c = LibraryProblem::choose(&settings).chooser.unwrap();
assert!(c.libraries.is_empty() && c.selected.is_none());
assert!(LibraryProblem::new("/x", "e").saves_nothing(), "the failed-library window still saves nothing");
drop(lock);
let _ = std::fs::remove_dir_all(&dir);
}
/// Every file under `dir`: relative path → (contents, modification time).
fn snapshot(dir: &Path) -> std::collections::BTreeMap<PathBuf, (Vec<u8>, std::time::SystemTime)> {
let mut out = std::collections::BTreeMap::new();
let mut todo = vec![dir.to_path_buf()];
while let Some(d) = todo.pop() {
for e in std::fs::read_dir(&d).unwrap() {
let p = e.unwrap().path();
let m = std::fs::metadata(&p).unwrap();
if m.is_dir() {
todo.push(p.clone());
}
let bytes = if m.is_file() { std::fs::read(&p).unwrap() } else { Vec::new() };
out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, m.modified().unwrap()));
}
}
out
}
/// Listing the recent libraries (and drawing the window) writes nothing into any of them: no
/// lock file, no owner file, nothing modified — free, held by another program or not a library.
#[test]
fn listing_the_libraries_leaves_every_folder_byte_identical() {
let dir = temp("readonly");
let (free, fresh, held) = (dir.join("Free"), dir.join("Fresh"), dir.join("Held"));
let (free, held_path) = (make_library(&free), make_library(&held));
// a library whose lock files were never made (copied over from elsewhere, say)
let fresh_path = make_library(&fresh);
for f in [lightcraft_catalog::lock::LOCK, lightcraft_catalog::lock::OWNER] {
let _ = std::fs::remove_file(fresh.join(f));
}
std::fs::create_dir_all(dir.join("Plain")).unwrap();
let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap();
let recent = vec![free.clone(), fresh_path.clone(), held_path.clone(), dir.join("Plain").to_string_lossy().into_owned()];
let before = snapshot(&dir);
// (mtime granularity: a write in the same instant would still change contents or add files)
let settings = AppSettings { recent_libraries: recent.clone(), library_path: free.clone(), ..AppSettings::default() };
let p = LibraryProblem::choose(&settings);
assert_eq!(snapshot(&dir), before, "checking the rows must not create or change any file");
assert!(p.chooser.as_ref().unwrap().libraries.iter().take(3).all(|l| l.unavailable.is_none()));
let mut h = asking(recent, &free, None);
settle(&mut h);
assert!(has(&h, "libraryRecent:2"));
drop(h);
assert_eq!(snapshot(&dir), before, "listing must not create or change any file");
assert!(!fresh.join(lightcraft_catalog::lock::LOCK).exists() && !fresh.join(lightcraft_catalog::lock::OWNER).exists());
drop(lock);
let _ = std::fs::remove_dir_all(&dir);
}
/// The app as the desktop host starts it with *Ask me*: no library open, the list showing.
fn asking(recent: Vec<String>, last: &str, picked: Option<String>) -> Headless {
let session = lightcraft_engine::Session::new().with_fs();
@@ -145,7 +197,7 @@ fn asking(recent: Vec<String>, last: &str, picked: Option<String>) -> Headless {
if let Some(p) = picked {
app.services.pick_folder = Some(Box::new(move || Some(p.clone())));
}
app.library_problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings));
app.library_problem = Some(LibraryProblem::choose(&app.ui.settings));
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
settle(&mut h);
h
@@ -206,7 +258,7 @@ fn return_opens_the_selected_library_and_a_failure_says_why() {
settle(&mut h);
assert!(h.app.session.library.is_none());
let p = h.app.library_problem.as_ref().unwrap();
assert!(p.error.contains("already open"), "{}", p.error);
assert!(p.error.contains("already open in another LightCraft"), "who has it: {}", p.error);
assert!(p.chooser.as_ref().unwrap().libraries[1].unavailable.is_some(), "greyed out now");
drop(lock);
// ↑ to Alpha, Return: it opens
+3 -1
View File
@@ -173,7 +173,9 @@ With Settings ▸ General ▸ At launch set to Ask me (`ui.set {"settings": {"at
default, opens the last library), or Alt/Option held during the first 2 s after the window appears, the desktop app
shows a window listing the recent libraries (issue #1290). `--library` and `LIGHTCRAFT_LIBRARY` skip it, and so does
`--memory`; `lightcraft-cli` never shows it. `ui.inspect` → `libraryProblem.chooser` is `{libraries: [{path,
lastOpened, available, reason}], selected, libraryOpen}` (else `null`; `libraryOpen: true` when it was opened over
lastOpened, available, reason}], selected, libraryOpen}` (`available` is checked read-only: the folder and its catalog
file are looked at, never locked or written, so a library another program has open shows as available and Open
reports who has it in `libraryProblem.error`) (else `null`; `libraryOpen: true` when it was opened over
the last library with Alt). Each row is the widget `libraryRecent:<index>`; the buttons are `button:libraryOpen`,
`button:libraryOpenAnother` (the folder dialog of `app.openLibrary`), `button:libraryNew` (`app.newLibrary`),
`button:libraryForget` (Remove from List) and `button:libraryQuit`; `ui.key` ArrowUp / ArrowDown move the selection,
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -5,8 +5,10 @@
### Choose which library opens at launch
- Settings ▸ General ▸ **At launch**: *Open the last library* (as before) or *Ask me*. With *Ask me*, LightCraft
starts with a window listing your recent libraries: each one's name, the folder it is in and when you last opened
it. One that is gone (moved, renamed, on a disconnected drive), open in another program or no longer a library is
greyed out with the reason. The last library is selected; **Open** (or Return, or a double-click) opens it,
it. One that is gone (moved, renamed, on a disconnected drive), can't be read or no longer holds a library is
greyed out with the reason. Making the list only looks: it never locks or writes anything in your libraries, so one
that another program has open is reported, with who has it, when you press **Open**, and the window stays. The
last library is selected; **Open** (or Return, or a double-click) opens it,
**Open Another…** asks for a folder, **New Library…** makes one, **Quit** (or Esc) quits. ↑ and ↓ move through
the list.
- Holding Alt (Option on a Mac) as the window appears shows the same list once, over the library that opened: open