mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:51:07 +00:00
#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:
@@ -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();
|
||||
|
||||
@@ -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 :"
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user