Open the catalog on a worker, so a launch is not a page-by-page read

The second thing standing between `android_main` and the first
`poll_events`, and the one that grows with the library rather than with
the APK.

`library_ui::open` called `show_catalog_now`, which called
`Catalog::open_verified`. That runs `PRAGMA quick_check`, which reads
every page of the database, and then `Catalog::open`, which takes a full
SQLite backup of the file before a migration and rewrites its structure
afterwards. On a 50,000-image library that is tens of megabytes of I/O
on a tablet's flash, and it happened before the window had painted
anything — so on Android it was counted against the five seconds the
input dispatcher allows, and on the desktop it was a launch that sat on
a blank window.

`dr_catalog::recovery`'s own module documentation says the check is
affordable "at startup, where a failure has a user in front of it who
can answer a question". That was the intent and it was not true: there
was no interface yet in which to ask. Now there is, because the open
happens on a worker and the answer arrives on a channel drained by a
timer — the same shape the scan, the thumbnails and the login already
use.

The gate the synchronous call provided is kept, and is the reason the
scan moved with it. `Catalog::open` succeeds on a damaged file whose
header survived, so a scan running beside an unanswered recovery
question writes ETags and image rows into damaged pages and turns a
catalog that had a backup into one where the backup is the only copy
left. So the scan now starts from the drain, on the two answers that
permit it, and not at all on `Corrupt`. `library-scanning` stays true
throughout, which hides the Rescan button and stops the gate being
merely advisory.

What the user sees while it runs is a third empty state. The grid
already refused to conflate "still scanning" with "scanned, found
nothing"; "opening the library" is a third answer and it gets its own
sentence, because a grid saying "Scanning…" while nothing is on the
network is the same kind of lie the other two were separated to avoid.

`show_catalog_now` stays, unchanged and blocking, for `recovery_ui`.
That call site has the event loop running, has just replaced the file
under a `forget_catalog`, and has `recovery-busy` on screen — the same
reasoning `recovery_ui::answer` already gives for doing its file copy in
place. The part both paths share is now `adopt_catalog`.

One consequence worth naming: the cache-usage figure on the settings
page was read at startup from a catalog that is no longer open by then.
It moves to the page's `on_open` closure, beside the face coverage,
which is read there for exactly the same reason — it is only ever looked
at while that page is on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 18:41:34 +02:00
co-authored by Claude Opus 5
parent 1c5849ebe8
commit d48e9f6033
4 changed files with 281 additions and 22 deletions
+15
View File
@@ -1285,6 +1285,12 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
{
// What the cache actually holds, so the ceiling above it is a figure
// the user can judge rather than an abstract one.
//
// Empty at this point on a launch and that is expected: the catalog it
// queries is being opened on a worker (`library_ui::open_catalog_soon`)
// and is not there yet. The figure is filled in when the settings page
// is opened, which is the only time it is looked at — see the `on_open`
// closure passed to `settings_ui::wire`.
settings.set_usage_label(describe_cache_usage(&library));
// Rendered once up front so the page is correct the first time it is
// opened, rather than on the second open after a callback has run.
@@ -1461,6 +1467,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// they are only ever looked at while this page is on screen.
let lib = library.clone();
let catalog = library.catalog();
let ctl = settings.clone();
move |w: &AppWindow| {
let store = lib.session().and_then(|c| {
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&c.account)).ok()
@@ -1471,6 +1478,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.and_then(|c| library::face_models(&c.account))
.is_none(),
);
// Here rather than at startup, on exactly the reasoning
// above: the catalog this reads is opened on a worker now,
// so a launch has nothing to describe, and the figure is
// only ever read while this page is on screen. `render`
// again because `settings_ui::wire` renders *before* it
// calls this, and the label is one of the things it draws.
ctl.set_usage_label(describe_cache_usage(&lib));
settings_ui::render(w, &ctl);
}
},
);