Split library_ui.rs into a module directory by area of behaviour

controller holds LibraryController and the window-sizing constants every
other module reads and writes through pub(super) fields, the same shape
collections_ui and develop already use. open is the launch-to-scan cycle and
the worker that checks the catalog file before either touches it. offline is
what of a collection is on this device and the prompt that offers to change
it. window fills the grid model from the catalog and drains the thumbnail
fetch, which is the piece the catalog-reads-are-proportional-to-what-changed
rule (docs/catalog.md §1) bears on most directly. sync is the background
passes that reach beyond the loaded window: the metadata sweep, the
whole-library thumbnail pass, and the exchange with the server.
ratings_keywords applies a judgement or a keyword to a selection and queues
the sidecar and XMP writes behind it. timeline is the capture-time sidebar
and the photographer's place together, kept in one file because a restored
place ends by moving the timeline marker and a scrub is a restore of one
instant, so most calls between the two would otherwise cross a module
boundary. grid wires the grid's own callbacks — the keyboard cursor,
cell-size zoom, the routes into and out of develop — and filter_bar wires
the rating, people, date and offline-scope filters, calling back into
whichever of the above owns the work a filter change triggers.

Extracted by item rather than by line range, so every doc comment and
TRACES/GESTURE annotation stayed attached to the code it describes; the
sorted set of TRACES/GESTURE lines in the new directory is identical to the
original file's. Tests moved with the code they exercise, including the
handful of fixtures — settle, model_of, with_catalog, zoom_cell, pinch_step
— that only one target module needed and so were not worth sharing through
a test_support module the way the other splits use one. Items that crossed
a new module boundary were widened from private to pub(super), narrower
than the whole-file access the original gave them; a few items already
pub(crate) for recovery_ui or presets stayed there rather than being
narrowed, since nothing needed them tightened further.

mod.rs re-exports the same surface library_ui:: callers used before, so
lib.rs and every other caller needed no change.
This commit is contained in:
2026-09-20 21:10:02 +02:00
parent 14ed1dc410
commit cc73ea3153
12 changed files with 8676 additions and 8412 deletions
+995
View File
@@ -0,0 +1,995 @@
//! Opening a library and bringing its catalog up to date: the launch path,
//! the background scan, and the worker that checks the catalog file before
//! either touches it.
//!
//! Split from the loading of one window over the catalog (`window`) because
//! this is the coarser cycle around it — once per library open or rescan
//! rather than once per scroll — and from the offline/pin bookkeeping
//! (`offline`) it hands off to on a failure. See `docs/dev/code-health.md`
//! CH-1.
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::mpsc::Receiver;
use dr_catalog::Catalog;
use dr_sync::{Account, AccountStore, Connection};
use dr_types::FormatFilter;
use slint::ComponentHandle;
use crate::library::{self, ScanMessage};
use crate::{AppWindow, View};
use super::controller::{stop, LibraryController};
use super::offline::refresh_offline;
use super::ratings_keywords::refresh_xmp_conflicts;
use super::sync::start_sweep;
use super::timeline::{apply_place, note_place};
use super::window::{load_window, window_start};
/// Reload the grid for the current scope and offset.
///
/// The reload entry point for everything outside this module — a scope change,
/// or a drop that altered the collection being shown.
pub fn reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
load_window(window, ctl);
// TRACES: FR-UI-8
// The scope changed, or the collection under it did. Either way this is the
// entry point every such change comes through, which is why the record is
// taken here rather than at each of the callers.
note_place(window, ctl);
}
/// Open a library: show the grid, start a scan, then fill in thumbnails.
///
/// Called from the launch screen's "Open library" button — the callback that
/// until now only logged its intent.
pub fn open(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
store: &AccountStore,
account: Account,
) {
// The credential is fetched only where the connector wants one; a folder
// library has none, and asking the keyring for it would fail the one
// backend that needs nothing.
let conn = match store.connection(&account, crate::remote::needs_secret(&account)) {
Ok(c) => c,
Err(e) => {
window.set_library_error(format!("credentials: {e}").into());
window.set_active_view(View::Library);
return;
}
};
let filter = account.format_filter();
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
// TRACES: FR-UI-8
// Where this library's position is kept, and what this device signs it
// with. Before the catalog opens, because `adopt_catalog` is what reads the
// record back and it is the next thing to run.
*ctl.place_store.borrow_mut() = Some(crate::place::PlaceStore::open_at(library::place_path(
&conn.account,
)));
*ctl.place_device.borrow_mut() = device_id(&conn.account);
// A fresh library is one nobody has moved in yet, so a place arriving from
// another device may still be applied — see `place_untouched`.
ctl.place_untouched.set(true);
window.set_active_view(View::Library);
window.set_library_open(true);
window.set_library_scanning(true);
window.set_library_error(slint::SharedString::new());
window.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(library_root_label(&conn.account).into());
// An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead.
if filter.is_empty() {
window.set_library_scanning(false);
window.set_library_error("No formats selected — tick at least one.".into());
return;
}
let path = library::catalog_path(&conn.account);
// Before the scan, not after it: the grid can be filled from disk now and
// the scan is only ever going to add to it.
//
// And it is the gate on the scan, not merely a prelude to it — a damaged
// catalog has a question on screen, and a scan writing into it while that
// question is unanswered is how the last good copy gets destroyed. That
// gate is why the scan starts from the drain below rather than from here:
// the answer no longer arrives on the next line.
// TRACES: FR-UI-8
// Started beside the catalog open rather than after it, so the handover has
// a round trip's head start on the user deciding what to look at. It
// resolves nothing until the catalog is there — see `fetch_remote_place`.
fetch_remote_place(window, &ctl, &coll_ctl, &conn);
open_catalog_soon(window, ctl, coll_ctl, conn, filter, path);
}
/// Open the catalog on a worker, then show it and start the scan.
///
/// # Why this is not `show_catalog_now` on the spot
///
/// It was, and it was the largest blocking call on the launch path.
/// [`Catalog::open_verified`] runs `PRAGMA quick_check`, which reads **every
/// page** of the database, and then `Catalog::open`, which takes a full copy of
/// the file before a migration and rewrites its structure. On a 50,000-image
/// library that is tens of megabytes of I/O, and all of it happened before the
/// window had painted anything.
///
/// On Android that is not a stutter but an ANR. `dr_ui::run` is called from
/// `android_main` and does not reach `window.run()` — the first `poll_events`,
/// and so the first time anything drains the activity's input channel — until
/// every line above it has finished. Five seconds of that and the system offers
/// to kill the app.
///
/// [`crate::recovery_ui`] and `recovery`'s own module documentation both say the
/// check belongs "at startup, where a failure has a user in front of it who can
/// answer a question". That was always the intent; this is what makes it true,
/// because the question can only be asked once there is an interface to ask it
/// in.
///
/// # What the user sees meanwhile
///
/// `library-opening`, which the grid's empty state draws as "Opening the
/// library…" rather than as "Scanning…". They are different answers — one is
/// reading a file on this device, the other is walking a tree over the network
/// — and the grid already refuses to conflate the two states it had.
///
/// `library-scanning` stays true throughout, which is what keeps the gate above
/// from being merely advisory: the header hides Rescan while it is set, so
/// there is no button that could start a scan into a catalog this has not
/// finished checking.
fn open_catalog_soon(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
conn: Connection,
filter: FormatFilter,
path: PathBuf,
) {
// Already open: a library being re-opened within one session, which is the
// case `show_catalog_now` short-circuited. There is nothing to wait for and
// nothing to say, so the scan starts immediately as it always did.
if ctl.catalog.borrow().is_some() {
begin_scan(window, &ctl, &coll_ctl, conn, filter, path);
return;
}
window.set_library_opening(true);
window.set_library_status("Reading the catalog on this device…".into());
let rx = spawn_catalog_open(path.clone());
let weak = window.as_weak();
let held = ctl.clone();
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
// Tighter than the scan's 120 ms: this one lands once, and everything
// the grid can show is waiting behind it.
std::time::Duration::from_millis(30),
move || {
let Some(w) = weak.upgrade() else { return };
let got = match rx.try_recv() {
Ok(got) => got,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
// The worker died without answering — a panic inside SQLite,
// realistically. Treated as "no catalog to show", which is what
// the synchronous path did with any error it could not
// classify: the scan is the thing that has to work.
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
CatalogOpen::Failed("the catalog worker stopped without answering".into())
}
};
stop(&held.catalog_timer);
w.set_library_opening(false);
match got {
CatalogOpen::Opened(cat) => {
adopt_catalog(&w, &held, &coll_ctl, cat);
begin_scan(
&w,
&held,
&coll_ctl,
conn.clone(),
filter.clone(),
path.clone(),
);
}
CatalogOpen::Corrupt(detail) => {
// No scan, and this is the whole reason the scan waits for
// this answer: `Catalog::open` succeeds on a file whose
// header survived, so a scan would write ETags and image
// rows into damaged pages while the user is still reading
// the question — turning a file that had a backup into one
// where the backup is the only copy left.
//
// `offer` clears the scanning state itself, for exactly
// that reason, so nothing here does it twice.
crate::recovery_ui::offer(&w, &path, &detail);
}
CatalogOpen::Failed(why) => {
// Not surfaced: this is a first run more often than it is
// anything else, the empty state already says the scan is
// running, and an error here would contradict a scan that
// is working perfectly. If the scan fails too, it reports
// for both of them.
log::info!("no catalog to show before the scan: {why}");
begin_scan(
&w,
&held,
&coll_ctl,
conn.clone(),
filter.clone(),
path.clone(),
);
}
}
},
);
*ctl.catalog_timer.borrow_mut() = Some(timer);
}
/// What the catalog worker found.
enum CatalogOpen {
/// Checked, opened, migrated forward and backfilled.
Opened(Catalog),
/// `PRAGMA quick_check` failed. Carries SQLite's own words, because the
/// message the user is shown is the diagnosis rather than a paraphrase.
Corrupt(String),
/// Anything else: no catalog file yet, a permission, a locked database.
Failed(String),
}
/// Check, open and migrate the catalog on a thread of its own.
///
/// `Catalog` holds a `rusqlite::Connection`, which is `Send` and not `Sync` —
/// exactly the shape that can be built on a worker and handed over once, which
/// is what this does. Nothing else touches the file while it runs: this is the
/// only opener on the launch path, and the scan that opens its own connection
/// does not start until the answer has landed.
fn spawn_catalog_open(path: PathBuf) -> Receiver<CatalogOpen> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let started = std::time::Instant::now();
let message = match Catalog::open_verified(&path) {
Ok(cat) => {
// The number this change exists for. Worth an `info` line on
// every launch: it is the one figure that says whether a slow
// start is the catalog or something else, and it is not
// measurable from anywhere else.
log::info!(
"catalog checked and opened in {} ms",
started.elapsed().as_millis()
);
CatalogOpen::Opened(cat)
}
Err(dr_catalog::CatalogError::Corrupt { detail }) => CatalogOpen::Corrupt(detail),
Err(e) => CatalogOpen::Failed(e.to_string()),
};
let _ = tx.send(message);
});
rx
}
/// Start the scan that brings the catalog up to date.
///
/// Split out of [`open`] because it no longer runs there — see
/// [`open_catalog_soon`] — and shared with the two paths that reach it once the
/// catalog has been answered for.
fn begin_scan(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
conn: Connection,
filter: FormatFilter,
path: PathBuf,
) {
log::info!(
"scanning {} for {} format(s) → {}",
if conn.account.root.is_empty() {
"<account root>"
} else {
&conn.account.root
},
filter.iter().count(),
path.display()
);
window.set_library_status("Starting…".into());
let rx = library::spawn_scan(
conn.clone(),
conn.account.root.clone(),
filter,
path.clone(),
);
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
}
/// Drain scan progress on the UI thread.
fn drain_scan(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
rx: Receiver<ScanMessage>,
catalog_path: std::path::PathBuf,
) {
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// Indeterminate for as long as it runs: a recursive walk discovers its own
// extent, so the count it reports is what it has *found*, never a fraction
// of what there is (FR-CAT-1).
//
// Named after the folder, because two accounts or two roots produce rows
// that are otherwise identical.
let title = match ctl.session.borrow().as_ref() {
Some((c, _)) if !c.account.root.is_empty() => {
format!("Scanning {}", c.account.root)
}
_ => "Scanning the library".to_string(),
};
let job = ctl.activity.begin(crate::activity::Kind::Scan, title);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || {
let Some(w) = weak.upgrade() else { return };
let ctl = &ctl_cb;
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// A worker that died without sending must not leave
// the screen on "Scanning…" forever.
if w.get_library_scanning() {
w.set_library_scanning(false);
w.set_library_error("scan ended unexpectedly".into());
job.fail("ended unexpectedly");
}
stop(&ctl.scan_timer);
return;
}
};
match msg {
ScanMessage::Progress {
directories,
pruned,
images,
} => {
// Pruned folders are reported separately rather than
// folded into the total: they are the ETag walk paying
// off, and hiding them makes an incremental rescan look
// identical to a full one.
let status = if pruned > 0 {
format!("{directories} folders · {pruned} unchanged · {images} images")
} else {
format!("{directories} folders · {images} images")
};
job.detail(status.clone());
w.set_library_status(status.into());
}
ScanMessage::Done {
found,
total,
pruned,
elapsed_ms,
judgements,
} => {
log::info!(
"scan complete: {total} images ({found} listed, \
{pruned} folders unchanged, {judgements} judgements \
taken in) in {elapsed_ms} ms"
);
w.set_library_scanning(false);
// A completed scan is the strongest possible evidence
// the server is reachable, so it clears offline mode
// without needing a probe of its own.
if ctl
.reachability
.borrow_mut()
.mark_reachable(std::time::Instant::now())
{
log::info!("back online");
}
// TRACES: FR-PLAT-AND-2
// And it is the only evidence that clears a lost root,
// for the same reason: the walk began by listing the
// root, so a scan that finished is a root that opened.
// The rows it marked offline are restored one at a
// time by `library::persist`, as each file is listed
// again — this only stops the banner claiming what is
// no longer true.
if ctl.root_lost.borrow_mut().take().is_some() {
log::info!("library folder is readable again");
}
refresh_offline(&w, ctl);
// TRACES: FR-CAT-13
// The pull may have found an `.xmp` that disagrees
// with the catalog; the settings page offers the
// reload, and this is what tells it how many.
refresh_xmp_conflicts(&w, ctl);
// An incremental rescan lists almost nothing, so
// reporting the listed count would read as "0 images"
// on a library that is simply up to date.
let secs = elapsed_ms as f64 / 1000.0;
let status = if pruned > 0 && found == 0 {
format!("up to date · {total} images · {secs:.1}s")
} else if pruned > 0 {
format!("{found} new or changed · {total} images · {secs:.1}s")
} else {
format!("{total} images · {secs:.1}s")
};
// TRACES: FR-CAT-8 | FR-NC-9
// Said out loud, because a grid that silently gains
// three hundred stars is indistinguishable from one
// that has gone wrong — and because for as long as
// this number was structurally zero, the photographer
// had no way to tell that a cull made on another
// device had failed to arrive.
let status = if judgements > 0 {
format!("{status} · {judgements} from other devices")
} else {
status
};
job.finish(status.clone());
w.set_library_status(status.into());
// Usually already open — `show_catalog_now` opened it
// before this scan started, so the grid has been
// showing the previous run's catalog all along and the
// rows this scan found are new rows in that same file.
// Only a first run, where there was nothing to open,
// reaches the second arm.
let opened = if ctl.catalog.borrow().is_some() {
Ok(())
} else {
Catalog::open(&catalog_path).map(|cat| {
*ctl.catalog.borrow_mut() = Some(cat);
})
};
match opened {
Ok(()) => {
// The sidebar is built before the grid: the
// grid's badges read collection membership, and
// the tree is where the catalog handle first
// becomes available to it.
{
let borrow = ctl.catalog.borrow();
if let Some(cat) = borrow.as_ref() {
crate::collections_ui::refresh_tree(&w, &coll_ctl, cat);
}
}
load_window(&w, ctl);
// Everything the grid did not touch: the rest
// of the library gets a thumbnail and a date,
// so the timeline describes all of it rather
// than the part that was scrolled past.
start_sweep(&w, ctl);
}
Err(e) => w.set_library_error(format!("opening catalog: {e}").into()),
}
stop(&ctl.scan_timer);
return;
}
ScanMessage::Failed {
message,
offline,
lost_root,
} => {
log::warn!("scan failed: {message}");
w.set_library_scanning(false);
// Recorded as a failure even where it is only the
// connection: the grid's offline banner says the server
// is unreachable, and this says which piece of work
// stopped because of it.
job.fail(message.clone());
if lost_root {
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The library folder itself could not be opened —
// a share withdrawn, an unplugged drive, and in
// time a revoked document-tree grant. The worker
// has already marked every row under this root
// offline and deleted none of them; this is the
// half the user sees.
//
// Tested first because it is also true that the
// library is unreachable, and the generic answer
// would be reached first and be less useful.
*ctl.root_lost.borrow_mut() = Some(message);
refresh_offline(&w, ctl);
// Same reason as the offline arm below: without
// this a launch that began with a revoked grant
// shows an empty grid, which is the one impression
// this whole path exists to avoid.
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
} else if offline {
// Not an error state. The catalog from the last
// successful scan is still on disk and still
// accurate for everything already indexed, so the
// grid keeps working — it simply cannot learn about
// anything added on the server since.
ctl.reachability
.borrow_mut()
.mark_unreachable(message, std::time::Instant::now());
refresh_offline(&w, ctl);
// Show whatever the catalog holds. Without this the
// grid stays empty on a launch that began offline,
// which is precisely the case offline mode exists
// for.
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
} else {
w.set_library_error(message.into());
}
stop(&ctl.scan_timer);
return;
}
}
}
},
);
*ctl.scan_timer.borrow_mut() = Some(timer);
}
/// Start a scan against the configured library.
///
/// Shared by the rescan button and by the offline banner's retry, which are
/// the same operation: a scan is the only request that both proves the server
/// is reachable and brings the catalog up to date. Keeping them one function
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
pub(crate) fn start_rescan(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some((conn, filter)) = ctl.session.borrow().clone() else {
return;
};
window.set_library_scanning(true);
window.set_library_error(slint::SharedString::new());
window.set_library_status("Rescanning…".into());
let path = library::catalog_path(&conn.account);
let rx = library::spawn_scan(
conn.clone(),
conn.account.root.clone(),
filter,
path.clone(),
);
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
}
/// TRACES: FR-CAT-9
/// Show the catalog after a scan that could not reach the server.
///
/// The whole point of offline mode: a scan is how the grid normally gets its
/// catalog handle, so a failed one previously left the library empty even
/// though a complete catalog was sitting on disk from the last successful run.
/// The images are all still there, their thumbnails are in the shards, and
/// rating and collecting them are local writes.
///
/// The sweep is deliberately **not** started — it exists to fetch headers over
/// the network, so offline it would do nothing but fail once per image.
fn open_catalog_for_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
catalog_path: &std::path::Path,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
if ctl.catalog.borrow().is_some() {
// Already open — a rescan that failed, rather than a launch that
// began offline. The grid is showing the catalog already.
load_window(window, ctl);
return;
}
match Catalog::open(catalog_path) {
Ok(cat) => {
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
*ctl.catalog.borrow_mut() = Some(cat);
load_window(window, ctl);
}
Err(e) => {
// No catalog and no server. This is the one genuinely empty case:
// a first run that never reached the server has nothing indexed.
log::warn!("offline with no local catalog: {e}");
window.set_library_error(
"Offline, and this library has not been scanned on this device yet.".into(),
);
}
}
}
/// How long a run of geometry changes has to stop for before the window is
/// reloaded against it.
///
/// Long enough to swallow a whole gesture's worth of steps, short enough that a
/// single deliberate step still feels immediate.
const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140);
/// Reload the window once the grid's geometry has stopped changing.
///
/// # Why this is deferred when a scroll is not
///
/// A pinch is not one zoom step, it is a stream of them, and every step
/// changes both the column count and the capacity — two reports. Each report
/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the
/// badges and ratings for every one of them and spawn a thumbnail batch,
/// synchronously, on the thread that is trying to draw the frame. Twice per
/// step. That is why zooming juddered while scrolling the same grid is smooth:
/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame.
///
/// None of it is urgent, because none of it is about *which* photographs are on
/// screen. A column change moves the cells and a zoom resizes them, but the
/// window holds the same images either way — the model already has them, and
/// the cells re-flow from `columns` and `cell-size` without Rust being involved
/// at all. What the reload actually recomputes is which cells begin a row, so
/// the month headings land in the right places, and which thumbnail size class
/// to ask for now. Both can wait for the gesture to finish.
///
/// # The anchor
///
/// Captured on the *first* report of a run rather than read when the timer
/// fires. As the grid re-flows, the viewport keeps its pixel offset while the
/// rows move underneath it, so the view drifts and reports its drift — reading
/// the anchor at the end would faithfully return to wherever it had wandered
/// to. Taking it at the start returns to the photograph the user was actually
/// looking at when they began the gesture.
pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
// Only the first report of a run sets it; the rest of the run reuses it.
if ctl.pending_anchor.get().is_none() {
ctl.pending_anchor.set(Some(ctl.resume_at.get()));
}
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || {
let Some(w) = weak.upgrade() else { return };
let anchor = ctl_cb.pending_anchor.take().unwrap_or(0);
if !w.get_library_visible() {
return;
}
// The window re-centred on the anchor, and the viewport sent back to
// it: cells are drawn at their absolute place in the library, so a
// change to `columns` moves every one of them and a viewport left
// where it was would be pointing at rows the window no longer covers.
let window_size = *ctl_cb.window.borrow();
let total = w.get_library_total().max(0) as usize;
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
load_window(&w, &ctl_cb);
w.set_library_scroll_to(anchor as i32);
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
});
// Replacing the slot drops the previous timer, which is what makes this
// coalesce: only the last report of a run lives long enough to fire.
*ctl.geometry_timer.borrow_mut() = Some(timer);
}
/// Show what the catalog already holds, without waiting for the scan.
///
/// **Blocking, and no longer on the launch path.** [`open_catalog_soon`] is
/// what a launch goes through; this is what [`crate::recovery_ui`] goes through
/// once the user has answered the damage question, where the event loop is
/// already running, the file has just been replaced under a `forget_catalog`,
/// and there is a `recovery-busy` state on screen saying so. Same reasoning as
/// `recovery_ui::answer` gives for doing the file copy itself in place: nothing
/// else is on screen to block, and a worker would buy a spinner and cost the
/// guarantee that nothing else touches the file while it is being replaced.
///
/// # Why a launch should not be a scan
///
/// A launch does not have to discover the library. The catalog from the last
/// run is on disk, complete, with its thumbnails in the shards beside it —
/// which is precisely the state [`open_catalog_for_offline`] already relies on
/// when the server cannot be reached. Every launch that *could* reach the
/// server threw that away and sat on "Scanning…" over an empty grid for as long
/// as a recursive WebDAV walk of the whole tree takes. On a real library, and
/// especially on a phone's connection, that walk is the entire startup time,
/// spent hiding a grid that was ready before it began.
///
/// The scan still runs and still replaces this the moment it lands. What it no
/// longer does is gate the first paint on the network.
///
/// Silent when there is no catalog yet: that is a genuine first run, the empty
/// state already says "Scanning…", and an error here would contradict a scan
/// that is working perfectly. `Catalog::open` creates the file in that case, so
/// what the grid reads is an empty catalog rather than a failure.
/// Returns whether it is safe to go on and scan.
///
/// `false` means the catalog is damaged and the recovery question is up. The
/// caller must not start a scan on that answer: `Catalog::open` succeeds on a
/// file whose header survived, so the scan would write ETags and image rows
/// into damaged pages while the user is still reading the question — turning a
/// file that had a backup into one where the backup is the only copy left.
pub(crate) fn show_catalog_now(
window: &AppWindow,
ctl: &Rc<LibraryController>,
catalog_path: &std::path::Path,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) -> bool {
if ctl.catalog.borrow().is_some() {
return true;
}
// Verified rather than plain: this is a moment where a full check is
// affordable and there is a user in front of it who can answer the
// question — they have just answered one. See `dr_catalog::recovery` for
// why it is not on every open.
let cat = match Catalog::open_verified(catalog_path) {
Ok(cat) => cat,
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
crate::recovery_ui::offer(window, catalog_path, &detail);
return false;
}
Err(e) => {
// Not surfaced: the scan is the thing that has to work, and it is
// still running. If it fails too, it reports for both of them.
log::info!("no catalog to show before the scan: {e}");
return true;
}
};
adopt_catalog(window, ctl, coll_ctl, cat);
true
}
/// Take an opened catalog into the interface.
///
/// The UI half of opening a catalog, separated from the opening itself so that
/// [`open_catalog_soon`] — which does the opening on a worker — and
/// [`show_catalog_now`] — which still does it in place, for
/// [`crate::recovery_ui`], where the event loop is already running and the file
/// has just been replaced — describe the grid the same way.
///
/// Everything here is UI-thread work by necessity: it fills the sidebar and the
/// grid model.
fn adopt_catalog(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
cat: Catalog,
) {
// Before anything else can see this catalog, and exactly once per open —
// the callers' early return on an already-open catalog is what makes it
// once. The job queue is durable, so a run that was killed mid-job left its
// row marked `Running` with nobody holding it; recovery hands those back to
// be resumed rather than lost, and drops jobs naming photographs that have
// since been deleted.
//
// Here rather than wherever a runner starts, because there is no owner
// column in `jobs`: a second recovery pass while a worker held a claim
// would take that claim away from it.
match dr_catalog::runner::recover(cat.connection()) {
Ok(r) if r.did_anything() => log::info!(
"job queue: {} interrupted job(s) resumed, \
{} for deleted photographs dropped",
r.reclaimed,
r.reaped
),
Ok(_) => {}
// Not surfaced. The queue is rebuildable like everything else in the
// catalog, and a grid that refuses to open because a background queue
// could not be tidied is the worse failure by some way.
Err(e) => log::warn!("job queue could not be recovered: {e}"),
}
// The sidebar before the grid, because the grid's badges read collection
// membership — the same order the scan's completion uses.
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
*ctl.catalog.borrow_mut() = Some(cat);
// TRACES: FR-UI-8
// Where the photographer was, before the first window is read rather than
// after. The scope and the filter both change what the grid's query
// returns, so restoring them afterwards would mean reading the top of the
// whole library, drawing it, and then reading again — a visible jump on
// every launch, and on a remote library a screenful of thumbnails fetched
// for photographs nobody asked to see.
//
// The sidebar is refreshed above rather than below for the same ordering
// reason: a scope is restored by naming a row of a tree that has to exist.
let stored = ctl.place_store.borrow().as_ref().and_then(|s| s.load());
match stored {
Some(place) => apply_place(window, ctl, coll_ctl, &place),
None => load_window(window, ctl),
}
}
/// TRACES: FR-UI-8
/// Ask the server where the photographer was, and use it if they have not
/// started working yet.
///
/// # The two halves of a restore
///
/// The local record is applied in [`adopt_catalog`], synchronously and before
/// the first window is read: it is on this disk, it costs nothing, and it works
/// with the network down. This is the other half — the record another device
/// left — and it cannot be applied there because it has not arrived yet.
///
/// # Why it may be ignored when it does arrive
///
/// A handover is welcome on the way in and unwelcome once the photographer has
/// started. A grid that jumped somewhere else mid-scroll, because a round trip
/// finally landed, would be worse than never handing over at all — the user did
/// not ask for it, cannot see why it happened, and has lost their place to a
/// feature whose entire purpose is keeping it.
///
/// So `place_untouched` gates it: any scroll, scrub, scope change, filter or
/// opened photograph closes the latch. The record is still adopted onto disk by
/// the exchange in [`crate::derived_sync::sync_place`], so nothing is lost —
/// it simply takes effect at the next launch instead of this one.
///
/// Silent throughout. A first launch against a library nobody has recorded a
/// place for is the ordinary case, and being offline is not a failure of
/// anything the user asked for.
fn fetch_remote_place(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
conn: &Connection,
) {
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
return;
}
let rx = crate::derived_sync::spawn_place_fetch(conn.clone(), conn.account.root.clone());
let timer = slint::Timer::default();
let weak = window.as_weak();
let held = ctl.clone();
let coll = coll_ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || {
let Some(w) = weak.upgrade() else { return };
let got = match rx.try_recv() {
Ok(got) => got,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop(&held.place_fetch_timer);
return;
}
};
stop(&held.place_fetch_timer);
let theirs = match got {
Ok(Some(place)) => place,
Ok(None) => return,
Err(e) => {
log::debug!("no place from the server: {e}");
return;
}
};
// Newer than what this device has, or there is nothing to learn.
//
// The borrow is scoped rather than dropped by hand: `apply_place`
// below reaches back into the controller, and a `Ref` still held
// across it is the shape a `RefCell` panic takes.
let adopted = match held.place_store.borrow().as_ref() {
Some(store) => store.adopt(&theirs),
None => false,
};
if !adopted {
return;
}
// On disk either way; on screen only if nobody has moved.
if !held.place_untouched.get() {
log::info!("a newer place arrived, and will be used at the next launch");
return;
}
// And only once the catalog is open — the ordinal it resolves to is
// a query against it. A record that lands first is left on disk,
// where `adopt_catalog` reads it.
if held.catalog.borrow().is_none() {
return;
}
log::info!("picking up where another device left off");
apply_place(&w, &held, &coll, &theirs);
},
);
*ctl.place_fetch_timer.borrow_mut() = Some(timer);
}
/// TRACES: FR-UI-8
/// What this device signs a place with.
///
/// The thumbnail store's client id, which is already this device's name among
/// the clients sharing a library — it is what qualifies a shard's remote
/// filename. Reusing it means the place and the shards a machine publishes
/// carry one name in `.darkroom-derived/`, rather than two that have to be
/// correlated by hand.
///
/// Empty where the store will not open. The field is informational, so an
/// unsigned place is worth strictly more than no place at all.
fn device_id(account: &Account) -> String {
match dr_thumbs::ThumbStore::open(&library::thumbs_dir(account)) {
Ok(store) => store.client_id().to_string(),
Err(e) => {
log::debug!("no device id for the place record: {e}");
String::new()
}
}
}
/// Drop the open catalog, so the next `show_catalog_now` opens the file
/// again rather than returning early.
///
/// Only recovery needs this, and it needs it for a specific reason: the file
/// under that connection has been replaced. A handle to the catalog that was
/// there before is a handle to a file that no longer has a name, and every
/// read through it would return the damaged pages the recovery just moved out
/// of the way.
pub(crate) fn forget_catalog(ctl: &Rc<LibraryController>) {
*ctl.catalog.borrow_mut() = None;
}
/// The line in the library header that says what is being catalogued.
///
/// For an account with a user, the user and the chosen subtree, or the
/// whole account when none was chosen. For a folder there is no user and
/// the endpoint is the library, so the folder's own name: `library`, or
/// `library/2026` when narrowed — never " · whole account", which is a
/// sentence about a server said of a directory.
fn library_root_label(account: &Account) -> String {
if account.login.is_empty() {
let folder = std::path::Path::new(&account.endpoint)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| account.endpoint.clone());
if account.root.is_empty() {
folder
} else {
format!("{folder}/{}", account.root)
}
} else if account.root.is_empty() {
format!("{} · whole account", account.user_id)
} else {
format!("{}/{}", account.user_id, account.root)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_header_names_a_folder_library_by_its_folder() {
let mut folder = Account::new("folder", "/var/tmp/dr-demo/library");
assert_eq!(library_root_label(&folder), "library");
folder.root = "2026".into();
assert_eq!(library_root_label(&folder), "library/2026");
let mut cloud =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
assert_eq!(library_root_label(&cloud), "duncan · whole account");
cloud.root = "PhotosRaw".into();
assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw");
}
}