Thirty-nine spawn sites in dr-ui, and one in the Android entry point, called std::thread::spawn or a Builder of their own, and most of the threads they started were <unnamed> in a panic message or a profiler. Each now calls executors::spawn with its executor and a role, so the thread is named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open — and knows which executor it is on. The three that already set a name (automation, import, prefetch) keep their name as the role. Behaviour is unchanged: each job still gets a thread of its own when it starts, and spawn panics where std::thread::spawn did. The module's documentation now says how a job is assigned: by what it spends its time on, so a sweep that fetches bytes and then decodes them is Decode, and a sidecar write that touches the catalog is Network. Left as they were: the segmentation and refine workers in masks_ui.rs, which another change is reworking, and test-only threads.
1034 lines
46 KiB
Rust
1034 lines
46 KiB
Rust
//! 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 crate::executors::{self, Executor};
|
|
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, Library, View};
|
|
|
|
use super::controller::{stop, LibraryController};
|
|
use super::offline::refresh_offline;
|
|
use super::ratings_keywords::refresh_xmp_conflicts;
|
|
use super::sync::{start_derived_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
|
|
.global::<Library>()
|
|
.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.global::<Library>().set_library_open(true);
|
|
window.global::<Library>().set_library_scanning(true);
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(slint::SharedString::new());
|
|
window
|
|
.global::<Library>()
|
|
.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
|
|
.global::<Library>()
|
|
.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.global::<Library>().set_library_scanning(false);
|
|
window
|
|
.global::<Library>()
|
|
.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.global::<Library>().set_library_opening(true);
|
|
window
|
|
.global::<Library>()
|
|
.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.global::<Library>().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();
|
|
|
|
executors::spawn(Executor::Io, "catalog-open", 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
|
|
.global::<Library>()
|
|
.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.global::<Library>().get_library_scanning() {
|
|
w.global::<Library>().set_library_scanning(false);
|
|
w.global::<Library>()
|
|
.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.global::<Library>().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.global::<Library>().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.global::<Library>().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);
|
|
// A scan is what finds a second copy.
|
|
crate::duplicates_ui::refresh_count(&w, cat);
|
|
}
|
|
}
|
|
load_window(&w, ctl);
|
|
// Take the server's shards and catalog *now*,
|
|
// before the sweep: the rows they key on exist
|
|
// from this moment, and on a fresh device
|
|
// every thumbnail, face and collection a peer
|
|
// has already made is on the server. Waiting
|
|
// for the sweep — hours on a large library —
|
|
// meant re-deriving all of it here first. In
|
|
// steady state this is one listing.
|
|
start_derived_sync(&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
|
|
.global::<Library>()
|
|
.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.global::<Library>().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.global::<Library>().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.global::<Library>().set_library_scanning(true);
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(slint::SharedString::new());
|
|
window
|
|
.global::<Library>()
|
|
.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.global::<Library>().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.global::<Library>().get_library_total().max(0) as usize;
|
|
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
|
|
load_window(&w, &ctl_cb);
|
|
w.global::<Library>().set_library_scroll_to(anchor as i32);
|
|
w.global::<Library>()
|
|
.set_library_scroll_token(w.global::<Library>().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, drops jobs naming photographs that have
|
|
// since been deleted, and drops rows of retired kinds — the thumbnail jobs
|
|
// 0.16.0 and earlier queued per photograph, which nothing claims (#73).
|
|
//
|
|
// 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, \
|
|
{} of a retired kind dropped",
|
|
r.reclaimed,
|
|
r.reaped,
|
|
r.retired
|
|
),
|
|
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);
|
|
crate::duplicates_ui::refresh_count(window, &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");
|
|
}
|
|
}
|