Say what the application is doing, in one bar and one list

Every background job reported into a window property of its own —
library-thumbs-done, library-pin-total, library-syncing — which only the
grid ever read. A pin download that outlived the view it was started from
drew nothing at all once the user opened an image, and there was no answer
anywhere to "what is this busy with", because the answer was spread across
eight properties nothing collected.

They report to one register now (ui/dr-ui/src/activity.rs). It publishes an
aggregate, which draws a three-pixel bar across the top of the shell in
every view, and a row per job, which the settings page lists: scans,
thumbnail batches, pin and open downloads, sidecar uploads, the sync and
the trash. Failures stay on the list until they are cleared; routine
successes do not, or a scroll would bury them.

The handle removes a still-running job when it drops, so a worker that dies
mid-transfer takes its row with it rather than leaving the bar sweeping for
the rest of the session.

Also carries in-flight work from a parallel session — the drawn icon set
and the dr-pipeline ops split. dr-pipeline's build script does not compile
at this commit; ui/dr-ui does, with clippy clean and its tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:32:09 +02:00
co-authored by Claude Opus 5
parent 70435b712e
commit 65e6a96a65
27 changed files with 3873 additions and 236 deletions
+613
View File
@@ -0,0 +1,613 @@
//! TRACES: FR-CAT-1 | FR-NC-6 | FR-NC-6c
//! One register of the work running behind the interface.
//!
//! Every background operation in this crate has the same shape: a worker
//! thread with an `mpsc` channel, drained by a `slint::Timer` on the UI thread.
//! Each of them used to report into a window property of its own —
//! `library-thumbs-done`, `library-pin-total`, `library-syncing` — and that had
//! two consequences worth removing.
//!
//! - **Only the grid could see them.** A pin download outlives the view it was
//! started from, so opening an image made an hour of transfers invisible: the
//! properties are read by `LibraryGrid` and nothing else.
//! - **There was no answer to "what is this doing".** The answer was spread
//! across eight properties that no code path ever collected, which is why the
//! settings page could not show a list of transfers: there was nothing to
//! list.
//!
//! So the jobs report here instead, and this publishes twice: an aggregate that
//! drives the bar across the top of the shell, and a row per job for the
//! settings page.
//!
//! # Everything here is single-threaded on purpose
//!
//! No `Arc`, no lock. The workers already hand their progress to the UI thread
//! through a channel, and the drain that reads it is the only thing that talks
//! to this register — so the shared-state problem was solved before this file
//! existed, and re-solving it with a mutex would only add a lock that is never
//! contended.
//!
//! # A job that stops reporting cannot hang the bar
//!
//! [`Activity`] is a handle whose `Drop` removes a still-running job. The drain
//! closures own their handle, so a worker that dies mid-transfer, a timer
//! replaced by a newer batch, or a window that closes all take their rows with
//! them. Without that, one lost `Finished` message would leave the bar sweeping
//! for the rest of the session — and a progress indicator that lies about
//! whether anything is happening is worse than none.
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
use slint::ComponentHandle;
use crate::{ActivityRow, AppWindow};
/// How many stopped jobs to keep.
///
/// A short history rather than none: "did the sync work?" is asked *after* the
/// sync, and a list that empties the instant a job ends can only ever answer
/// questions about the present. Short, because it is a status list and not a
/// log — the ones worth keeping past this are failures, and those are held
/// until the user clears them.
const KEEP_STOPPED: usize = 8;
/// How often the register publishes into the window.
///
/// Publishing on every change would rebuild the row model once per drained
/// message, and a thumbnail batch drains a hundred in a tick. This is fast
/// enough that a bar looks live and slow enough that a burst of completions
/// costs one update rather than a hundred.
const PUBLISH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
/// What kind of work a job is.
///
/// Coarser than the set of functions that start jobs: the user's question is
/// "is something downloading", not which of three call sites issued the fetch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// Walking the remote library (FR-CAT-1).
Scan,
/// Reading capture times across the whole library.
Index,
/// Previews for the cells on screen.
Thumbnails,
/// Original files coming down — a pin, or an image being opened.
Download,
/// Sidecars and derived data going up.
Upload,
/// The two-way exchange of catalog and shards.
Sync,
/// Moving files to the trash, restoring them, or emptying it.
Trash,
}
impl Kind {
/// Whether this job moves bytes over the network.
///
/// FR-NC-6c: the user's question on a slow or metered connection is about
/// *transfers* specifically, so the row says which jobs are ones.
pub fn is_transfer(self) -> bool {
match self {
Kind::Scan
| Kind::Thumbnails
| Kind::Download
| Kind::Upload
| Kind::Sync
| Kind::Trash => true,
// Reads headers over the network today, but it is bounded by the
// catalog rather than by anything the user asked to move, and it
// stores nothing. Calling it a transfer would put an hours-long
// background sweep in the same sentence as a download they are
// waiting on.
Kind::Index => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Running,
Done,
Failed,
}
/// One job's row in the register.
#[derive(Debug, Clone)]
struct Record {
id: u64,
kind: Kind,
title: String,
detail: String,
done: usize,
/// Zero means "no denominator" — a directory walk cannot know its extent,
/// and a percentage invented for it is worse than admitting as much.
total: usize,
state: State,
}
impl Record {
fn fraction(&self) -> f32 {
if self.total == 0 {
return 0.0;
}
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
}
fn row(&self) -> ActivityRow {
ActivityRow {
title: self.title.clone().into(),
detail: self.detail.clone().into(),
fraction: self.fraction(),
determinate: self.total > 0,
running: self.state == State::Running,
failed: self.state == State::Failed,
transfer: self.kind.is_transfer(),
}
}
}
/// The aggregate the shell's bar is drawn from.
#[derive(Debug, Clone, PartialEq)]
pub struct Summary {
pub running: usize,
/// Stopped jobs still listed — recent successes and unread failures.
pub kept: usize,
/// `None` where nothing running knows its own extent.
pub fraction: Option<f32>,
}
/// Every background job the application is running.
pub struct ActivityLog {
records: RefCell<Vec<Record>>,
next_id: Cell<u64>,
/// Where to publish. Absent in tests, which is what makes the whole
/// register testable without a window.
window: RefCell<Option<slint::Weak<AppWindow>>>,
pump: RefCell<Option<slint::Timer>>,
dirty: Cell<bool>,
}
impl Default for ActivityLog {
/// An empty register attached to nothing.
///
/// Publishing is a no-op until [`ActivityLog::attach`] gives it a window,
/// which is what lets a controller hold one in a test with no interface
/// running — and what makes this file's own tests possible.
fn default() -> Self {
Self {
records: RefCell::new(Vec::new()),
next_id: Cell::new(1),
window: RefCell::new(None),
pump: RefCell::new(None),
dirty: Cell::new(false),
}
}
}
impl ActivityLog {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
}
/// Start publishing into `window`.
///
/// The timer holds a *weak* reference back: an `Rc` here would be a cycle
/// through the closure, and the register would outlive the window it is
/// describing.
pub fn attach(self: &Rc<Self>, window: &AppWindow) {
*self.window.borrow_mut() = Some(window.as_weak());
let weak = Rc::downgrade(self);
let timer = slint::Timer::default();
timer.start(slint::TimerMode::Repeated, PUBLISH_INTERVAL, move || {
let Some(log) = Weak::upgrade(&weak) else {
return;
};
if log.dirty.replace(false) {
log.publish();
}
});
*self.pump.borrow_mut() = Some(timer);
// Once immediately, so an empty register starts out saying so rather
// than leaving whatever the properties defaulted to.
self.publish();
}
/// Register a job and hand back the handle that reports on it.
pub fn begin(self: &Rc<Self>, kind: Kind, title: impl Into<String>) -> Activity {
let id = self.next_id.get();
self.next_id.set(id + 1);
self.records.borrow_mut().push(Record {
id,
kind,
title: title.into(),
detail: String::new(),
done: 0,
total: 0,
state: State::Running,
});
self.touch();
Activity {
log: self.clone(),
id,
}
}
/// Drop every stopped job, keeping what is still running.
pub fn clear_finished(&self) {
self.records
.borrow_mut()
.retain(|r| r.state == State::Running);
self.touch();
}
pub fn summary(&self) -> Summary {
let records = self.records.borrow();
let running: Vec<&Record> = records
.iter()
.filter(|r| r.state == State::Running)
.collect();
// Summed across jobs rather than averaged: two downloads of wildly
// different size are one wait as far as the person watching is
// concerned, and averaging their percentages would have the bar jump
// backwards whenever a small job joined a large one.
let (done, total) = running
.iter()
.filter(|r| r.total > 0)
.fold((0usize, 0usize), |(d, t), r| (d + r.done, t + r.total));
Summary {
running: running.len(),
kept: records.len() - running.len(),
fraction: (total > 0).then(|| (done as f32 / total as f32).clamp(0.0, 1.0)),
}
}
/// The rows the settings page lists, running jobs first.
///
/// Stable within each group — insertion order — so a list being watched
/// does not reshuffle itself under the reader every time a count changes.
fn rows(&self) -> Vec<ActivityRow> {
let records = self.records.borrow();
records
.iter()
.filter(|r| r.state == State::Running)
.chain(records.iter().filter(|r| r.state != State::Running))
.map(Record::row)
.collect()
}
fn publish(&self) {
let Some(window) = self.window.borrow().as_ref().and_then(slint::Weak::upgrade) else {
return;
};
// Built before anything is written, so no property setter can run with
// `records` borrowed.
let rows = self.rows();
let summary = self.summary();
window.set_activity_rows(slint::ModelRc::new(slint::VecModel::from(rows)));
window.set_activity_busy(summary.running > 0);
window.set_activity_running(summary.running as i32);
window.set_activity_kept(summary.kept as i32);
window.set_activity_determinate(summary.fraction.is_some());
window.set_activity_fraction(summary.fraction.unwrap_or(0.0));
}
fn touch(&self) {
self.dirty.set(true);
}
/// Edit a *running* job.
///
/// A stopped one is left alone: a drain loop reads whatever the channel
/// still holds after the message that ended the job, and a queued progress
/// update arriving second would otherwise overwrite the failure the user
/// needs to read with a count that no longer means anything.
fn with(&self, id: u64, f: impl FnOnce(&mut Record)) {
if let Some(record) = self
.records
.borrow_mut()
.iter_mut()
.find(|r| r.id == id && r.state == State::Running)
{
f(record);
}
self.touch();
}
/// Stop a job, keeping it in the list.
///
/// First outcome wins, for the reason given on [`ActivityLog::with`]: a
/// worker that reports a failure and then closes its channel has failed,
/// and the closing must not relabel it as finished.
fn stop(&self, id: u64, state: State, detail: String) {
{
let mut records = self.records.borrow_mut();
let Some(record) = records
.iter_mut()
.find(|r| r.id == id && r.state == State::Running)
else {
return;
};
record.state = state;
record.detail = detail;
// A finished job reads as complete whatever it counted: a scan that
// ends having found forty of an unknown number is done, not 40%.
if state == State::Done && record.total > 0 {
record.done = record.total;
}
trim(&mut records);
}
self.touch();
}
/// Take a running job out of the register entirely.
///
/// Returns whether there was one to remove, which is how [`Activity::drop`]
/// tells "the drain forgot about this" from "it stopped properly".
fn remove(&self, id: u64) -> bool {
let removed = {
let mut records = self.records.borrow_mut();
let before = records.len();
records.retain(|r| !(r.id == id && r.state == State::Running));
records.len() != before
};
self.touch();
removed
}
}
/// Drop the oldest stopped jobs past [`KEEP_STOPPED`].
///
/// Failures are exempt: a transfer that failed while the user was elsewhere is
/// the single most useful thing this list holds, and letting eight successful
/// thumbnail batches push it out would lose exactly the row worth keeping.
fn trim(records: &mut Vec<Record>) {
let mut excess = records
.iter()
.filter(|r| r.state == State::Done)
.count()
.saturating_sub(KEEP_STOPPED);
records.retain(|r| {
if excess > 0 && r.state == State::Done {
excess -= 1;
return false;
}
true
});
}
/// Bytes as a figure to put beside a transfer.
///
/// Three scales rather than one: a sidecar is a few kilobytes and a RAW file is
/// tens of megabytes, and a single unit makes one of them read as "0.0" or as
/// six digits. One decimal at most — this is a status line, not a measurement.
pub fn describe_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * KB;
const GB: f64 = 1024.0 * MB;
let b = bytes as f64;
if b >= GB {
format!("{:.1} GB", b / GB)
} else if b >= MB {
format!("{:.1} MB", b / MB)
} else {
format!("{:.0} kB", (b / KB).ceil())
}
}
/// A running job, held by whatever is reporting on it.
///
/// Every method is idempotent and every one is a no-op once the job has
/// stopped, because the drains that call them are loops over a channel that may
/// deliver a late message after the one that ended the job.
pub struct Activity {
log: Rc<ActivityLog>,
id: u64,
}
impl Activity {
/// What the job is currently doing, in the job's own words.
pub fn detail(&self, detail: impl Into<String>) {
let detail = detail.into();
self.log.with(self.id, |r| r.detail = detail);
}
/// How much work there is. Zero leaves the job indeterminate.
pub fn total(&self, total: usize) {
self.log.with(self.id, |r| r.total = total);
}
/// More work turned up mid-flight — a batch that grew once the plan came
/// back. Added rather than replaced, so the count already served stays
/// meaningful.
pub fn add_total(&self, extra: usize) {
self.log.with(self.id, |r| r.total += extra);
}
/// One unit of work finished.
pub fn advance(&self) {
self.log.with(self.id, |r| r.done += 1);
}
/// Set both counts at once, for workers that report a running total.
pub fn progress(&self, done: usize, total: usize) {
self.log.with(self.id, |r| {
r.done = done;
r.total = total;
});
}
/// The job finished. `note` is what the list shows afterwards.
pub fn finish(&self, note: impl Into<String>) {
self.log.stop(self.id, State::Done, note.into());
}
/// The job finished and is not worth remembering.
///
/// For routine work the user never asked for by name: scrolling the grid
/// starts a thumbnail batch every second or so, and keeping those would
/// push a failed transfer out of the list within moments of it happening.
/// A failure is still kept — only [`Activity::finish`]'s history is
/// skipped.
pub fn finish_quietly(&self) {
self.log.remove(self.id);
}
/// The job stopped without doing what it set out to do.
///
/// Kept in the list until the user clears it: this is the row they came to
/// the settings page to find.
pub fn fail(&self, message: impl Into<String>) {
self.log.stop(self.id, State::Failed, message.into());
}
}
impl Drop for Activity {
/// A job whose handle goes away while it is still running is removed, not
/// marked failed. Nobody is coming back to report on it, and a row frozen
/// at "downloading, 12 of 900" would sit in the list claiming to be live
/// for the rest of the session.
fn drop(&mut self) {
if self.log.remove(self.id) {
log::debug!("a background job ended without saying so");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_running_job_makes_the_bar_busy() {
let log = ActivityLog::new();
assert_eq!(log.summary().running, 0);
let scan = log.begin(Kind::Scan, "Scanning");
assert_eq!(log.summary().running, 1);
// A scan has no denominator, so the bar must not claim a position.
assert_eq!(log.summary().fraction, None);
scan.finish("up to date");
assert_eq!(log.summary().running, 0);
}
#[test]
fn progress_is_summed_across_jobs() {
let log = ActivityLog::new();
let a = log.begin(Kind::Thumbnails, "Thumbnails");
let b = log.begin(Kind::Download, "Downloading");
a.progress(1, 10);
b.progress(4, 10);
// 5 of 20, not the average of 10% and 40%.
assert_eq!(log.summary().fraction, Some(0.25));
}
#[test]
fn a_job_without_a_denominator_does_not_dilute_one_that_has_it() {
let log = ActivityLog::new();
let scan = log.begin(Kind::Scan, "Scanning");
let fetch = log.begin(Kind::Download, "Downloading");
fetch.progress(3, 4);
scan.detail("120 folders");
assert_eq!(log.summary().fraction, Some(0.75));
}
#[test]
fn a_dropped_handle_takes_its_row_with_it() {
let log = ActivityLog::new();
{
let _thumbs = log.begin(Kind::Thumbnails, "Thumbnails");
assert_eq!(log.summary().running, 1);
}
// The worker died, or a newer batch replaced the timer holding this.
// Either way the bar must not sweep for ever.
assert_eq!(log.summary().running, 0);
assert_eq!(log.summary().kept, 0);
}
#[test]
fn a_finished_job_is_kept_and_reads_as_complete() {
let log = ActivityLog::new();
let sweep = log.begin(Kind::Index, "Indexing");
sweep.progress(40, 100);
// Ended early with everything it was going to do done.
sweep.finish("4000 dated");
drop(sweep);
let summary = log.summary();
assert_eq!(summary.running, 0);
assert_eq!(summary.kept, 1, "a finished job stays in the list");
let rows = log.rows();
assert_eq!(rows[0].fraction, 1.0);
assert!(!rows[0].running);
assert!(!rows[0].failed);
}
#[test]
fn a_failure_survives_a_run_of_successes() {
let log = ActivityLog::new();
log.begin(Kind::Download, "Downloading")
.fail("host is down");
for i in 0..KEEP_STOPPED * 2 {
log.begin(Kind::Thumbnails, format!("Batch {i}"))
.finish("done");
}
let rows = log.rows();
assert!(
rows.iter().any(|r| r.failed),
"the one row worth keeping was pushed out by routine successes"
);
assert!(rows.len() <= KEEP_STOPPED + 1, "history is bounded");
}
#[test]
fn clearing_leaves_running_jobs_alone() {
let log = ActivityLog::new();
let scan = log.begin(Kind::Scan, "Scanning");
log.begin(Kind::Sync, "Syncing").fail("timed out");
log.clear_finished();
assert_eq!(log.summary().kept, 0);
assert_eq!(log.summary().running, 1);
drop(scan);
}
#[test]
fn running_jobs_are_listed_first() {
let log = ActivityLog::new();
log.begin(Kind::Sync, "Syncing").finish("nothing to do");
let scan = log.begin(Kind::Scan, "Scanning");
let rows = log.rows();
assert_eq!(rows[0].title, "Scanning");
assert!(rows[0].running);
drop(scan);
}
#[test]
fn a_late_message_cannot_restart_a_stopped_job() {
let log = ActivityLog::new();
let fetch = log.begin(Kind::Download, "Downloading");
fetch.fail("connection reset");
// The drain reads one more queued message before it notices.
fetch.advance();
assert_eq!(log.summary().running, 0);
assert!(log.rows()[0].failed);
}
}
+40 -2
View File
@@ -142,11 +142,20 @@ pub struct CollectionsController {
/// the tree, the grid cells — and doing that from inside the `dropped`
/// handler destroys the elements Slint is still using to deliver the event.
dropped_on: RefCell<Option<CollectionId>>,
/// Where the trash workers report what they are doing.
///
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
/// one list, and the user asking what the application is busy with does not
/// care which module started them.
activity: Rc<crate::activity::ActivityLog>,
}
impl CollectionsController {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
..Default::default()
})
}
/// Which collection the grid is scoped to, for [`crate::library_ui`] to
@@ -786,6 +795,7 @@ fn start_trash(
// survive as a set of ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
@@ -800,6 +810,7 @@ fn start_trash(
catalog.clone(),
rx,
reload.clone(),
format!("Moving {count} photograph(s) to the trash"),
);
}
@@ -861,6 +872,7 @@ fn start_restore(
// would survive as ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
creds,
sess.user_id.clone(),
@@ -875,6 +887,7 @@ fn start_restore(
catalog.clone(),
rx,
reload.clone(),
format!("Restoring {count} photograph(s)"),
);
}
@@ -888,12 +901,22 @@ fn drain_trash(
catalog: Rc<RefCell<Option<Catalog>>>,
rx: std::sync::mpsc::Receiver<crate::trash::TrashMessage>,
reload: Rc<dyn Fn()>,
// What the register calls this operation. Passed in rather than derived
// here: the three callers move files to the trash, back out of it, and
// delete them outright, and "Deleting 40 photographs" is the one word of
// the three that must not appear over a restore.
title: String,
) {
use crate::trash::TrashMessage;
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// A server-side MOVE per file, so it is a transfer in the sense that
// matters: it takes as long as the connection is slow, and it can fail
// halfway with the library in two states at once.
let job = ctl.activity.begin(crate::activity::Kind::Trash, title);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
@@ -907,6 +930,7 @@ fn drain_trash(
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// A worker that died without reporting must not leave the
// status line mid-sentence.
job.fail("stopped without finishing");
stop_trash(&ctl_cb);
return;
}
@@ -923,6 +947,10 @@ fn drain_trash(
} else {
format!("{done} / {total}")
};
job.progress(done, total);
if failed > 0 {
job.detail(format!("{failed} failed"));
}
w.set_library_status(status.into());
}
TrashMessage::Done { moved, failed } => {
@@ -934,6 +962,14 @@ fn drain_trash(
} else {
format!("{moved} done · {} failed", failed.len())
};
// A partial failure is a failure in the register: the
// library is now in two states at once, which is
// exactly the thing worth keeping on the list.
if failed.is_empty() {
job.finish(status.clone());
} else {
job.fail(status.clone());
}
w.set_library_status(status.into());
if let Some(first) = failed.first() {
w.set_collection_error(first.as_str().into());
@@ -1396,6 +1432,7 @@ pub fn wire<S, R, C>(
log::info!("emptying trash: {} image(s)", ids.len());
w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into());
let count = ids.len();
let rx = crate::trash::spawn_purge(
creds,
@@ -1412,6 +1449,7 @@ pub fn wire<S, R, C>(
catalog.clone(),
rx,
reload.clone(),
format!("Deleting {count} photograph(s) permanently"),
);
});
}
+26 -2
View File
@@ -14,6 +14,7 @@
//! pipeline what parameters it has and builds a control per answer; no code
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
mod activity;
mod collections_ui;
mod derived_sync;
mod develop;
@@ -434,6 +435,18 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let window = AppWindow::new()?;
// Every background job reports here, and this draws the bar across the top
// of the shell and fills the settings page's list. Built before the
// controllers because they take a handle to it: a job that starts during
// startup — the scan a resumed session begins immediately — has to have
// somewhere to report to before it starts, or its first minute is invisible.
let activity = activity::ActivityLog::new();
activity.attach(&window);
{
let activity = activity.clone();
window.on_activity_clear_finished(move || activity.clear_finished());
}
// Set once `show` exists; see where the library grid is wired below.
#[allow(clippy::type_complexity)]
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = Rc::new(RefCell::new(None));
@@ -449,7 +462,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Declared out here rather than inside the launch block below because the
// develop side reads `paths` to rebuild its browsing list when an image is
// opened from the grid.
let library = library_ui::LibraryController::new();
let library = library_ui::LibraryController::new(activity.clone());
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
@@ -460,7 +473,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
let collections = collections_ui::CollectionsController::new();
let collections = collections_ui::CollectionsController::new(activity.clone());
// The click handler needs `show`, which is built further down because
// it captures the develop session and the GPU context. This cell is
@@ -860,6 +873,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let redraw = redraw.clone();
let rows = rows.clone();
let gpu = gpu.clone();
let activity = activity.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
@@ -895,6 +909,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
// The one transfer the user is actively waiting on. It gets a row
// like any other, so a download that is still running after they
// give up and go back to the grid is still accounted for.
//
// No denominator: `spawn_full_fetch` reports a result, not bytes as
// they arrive, so the honest bar here is the indeterminate one.
let job = activity.begin(activity::Kind::Download, format!("Downloading {name}"));
// Polled on the UI thread rather than joined: a join would freeze
// the window for the length of the download.
let weak = w.as_weak();
@@ -916,6 +938,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let bytes = match got {
Ok(b) => b,
Err(e) => {
job.fail(e.message.clone());
log::warn!("{name}: {e}");
// Offline needs its own words. "network error:
// connection refused" over a photograph the user
@@ -931,6 +954,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
return;
}
};
job.finish(activity::describe_bytes(bytes.len() as u64));
log::info!("{name}: {} bytes fetched", bytes.len());
match load_bytes(gpu.as_ref(), &bytes) {
+123 -28
View File
@@ -198,11 +198,19 @@ pub struct LibraryController {
/// small budget still keeps a working set. A metered or small-disk device
/// wants the first.
keep_opened: std::cell::Cell<bool>,
/// Where every worker this module starts reports what it is doing.
///
/// Held on the controller rather than passed to each function because the
/// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a
/// reconnect — and threading a second argument through all of them would
/// say nothing except that they all report progress.
activity: Rc<crate::activity::ActivityLog>,
}
impl LibraryController {
pub fn new() -> Rc<Self> {
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
catalog: Rc::new(RefCell::new(None)),
paths: RefCell::new(Vec::new()),
file_ids: RefCell::new(Vec::new()),
@@ -536,6 +544,20 @@ fn drain_scan(
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((_, session, _)) if !session.root.is_empty() => {
format!("Scanning {}", session.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),
@@ -553,6 +575,7 @@ fn drain_scan(
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;
@@ -574,6 +597,7 @@ fn drain_scan(
} else {
format!("{directories} folders · {images} images")
};
job.detail(status.clone());
w.set_library_status(status.into());
}
ScanMessage::Done {
@@ -611,6 +635,7 @@ fn drain_scan(
} else {
format!("{total} images · {secs:.1}s")
};
job.finish(status.clone());
w.set_library_status(status.into());
match Catalog::open(&catalog_path) {
@@ -636,6 +661,11 @@ fn drain_scan(
ScanMessage::Failed { message, offline } => {
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 offline {
// Not an error state. The catalog from the last
@@ -829,6 +859,14 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The longest-running transfer the app does, and the one most likely to be
// watched from another view — which is the whole reason the register
// exists (FR-NC-6, FR-NC-6c).
let job = ctl.activity.begin(
crate::activity::Kind::Download,
"Keeping photographs on this device",
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
@@ -840,6 +878,7 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_pin_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.pin_timer);
return;
}
@@ -849,9 +888,11 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::PinMessage::Planned { total } => {
w.set_library_pin_total(total as i32);
w.set_library_pin_done(0);
job.total(total);
}
library::PinMessage::Stored { done } => {
w.set_library_pin_done(done as i32);
job.progress(done, w.get_library_pin_total() as usize);
// The "On this device" count grows as they land, so
// the chip agrees with the progress line beside it.
refresh_local_count(&w, &ctl_cb);
@@ -861,6 +902,10 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
"pin complete: {stored} original(s), {:.1} MB",
bytes as f64 / 1_048_576.0
);
job.finish(format!(
"{stored} photograph(s) · {}",
crate::activity::describe_bytes(bytes)
));
w.set_library_pin_total(0);
w.set_library_pin_done(0);
refresh_local_count(&w, &ctl_cb);
@@ -869,6 +914,7 @@ fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
library::PinMessage::Failed { message, offline } => {
log::warn!("pin fetch stopped: {message}");
job.fail(message.clone());
w.set_library_pin_total(0);
if offline {
ctl_cb
@@ -1410,12 +1456,21 @@ fn start_sidecar_writes(
return;
};
let count = writes.len();
let rx = library::spawn_sidecar_writes(creds, session.user_id.clone(), writes);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The writer reports once at the end, so there is no per-file progress to
// show — but a cull that has just rated forty frames has forty uploads in
// flight, and "is that saved yet" deserves an answer somewhere.
let job = ctl.activity.begin(
crate::activity::Kind::Upload,
format!("Saving {count} judgement(s)"),
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
@@ -1435,6 +1490,10 @@ fn start_sidecar_writes(
"{failed} sidecar write(s) failed: {}",
last_error.clone().unwrap_or_default()
);
job.fail(format!(
"{written} saved · {failed} failed: {}",
last_error.clone().unwrap_or_default()
));
// Said plainly, because the consequence is specific:
// the rating is safe in the catalog but will not
// survive deleting it.
@@ -1447,6 +1506,9 @@ fn start_sidecar_writes(
);
} else {
log::debug!("{written} sidecar(s) written");
// Quietly: a cull produces one of these every few
// seconds and none of them is news.
job.finish_quietly();
}
stop(&ctl_cb.sidecar_timer);
}
@@ -1525,11 +1587,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
return;
}
// Reset the counter to this batch, so the bar measures the work actually
// outstanding rather than accumulating across batches.
window.set_library_thumbs_total(wanted.len() as i32);
window.set_library_thumbs_done(0);
let requested = wanted.len();
let rx = library::spawn_thumbnails(
creds,
session.user_id.clone(),
@@ -1537,7 +1595,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::thumbs_dir(&session.server, &session.user_id),
library::catalog_path(&session.server, &session.user_id),
);
drain_thumbnails(window.as_weak(), ctl.clone(), rx);
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested);
}
/// Apply thumbnails to the model as they arrive.
@@ -1545,9 +1603,19 @@ fn drain_thumbnails(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
rx: Receiver<ThumbnailMessage>,
requested: usize,
) {
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// One row per batch, measured against the cells this window asked for.
// Starting a new batch does not extend the last one: a scroll abandons
// whatever the previous window wanted, and a denominator carried across
// both would describe neither.
let job = ctl
.activity
.begin(crate::activity::Kind::Thumbnails, "Loading thumbnails");
job.total(requested);
// Which window this batch was requested for. Captured at spawn, compared on
// every tick.
let mine = ctl.generation.get();
@@ -1589,7 +1657,11 @@ fn drain_thumbnails(
// The worker finished or died. Either way nothing more
// is coming, so the bar must not sit part-filled
// forever.
w.set_library_thumbs_done(w.get_library_thumbs_total());
//
// Quietly: a scroll starts one of these every second,
// and a history of them would bury anything worth
// reading.
job.finish_quietly();
stop(&ctl_cb.thumb_timer);
return;
}
@@ -1606,7 +1678,7 @@ fn drain_thumbnails(
// Date reads produce no cell, so they are counted into
// the bar's denominator or it finishes while work is
// still running.
w.set_library_thumbs_total(w.get_library_thumbs_total() + dating as i32);
job.add_total(dating);
let mut parts = Vec::new();
if cached > 0 {
@@ -1619,12 +1691,14 @@ fn drain_thumbnails(
parts.push(format!("reading {dating} dates"));
}
if !parts.is_empty() {
w.set_library_status(parts.join(" · ").into());
let status = parts.join(" · ");
job.detail(status.clone());
w.set_library_status(status.into());
}
}
// A header-only date read. Advances the bar; draws nothing.
ThumbnailMessage::DateProgress => {
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
job.advance();
}
// Dates landed, so the histogram can now be built. This is
// what makes the timeline appear on a library whose
@@ -1640,7 +1714,7 @@ fn drain_thumbnails(
// successes would stall it on a library where some files
// carry no embedded preview.
ThumbnailMessage::Ready(t) => {
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
job.advance();
// Bytes arrived *from the server*, so it is reachable.
// This is what clears the banner when a connection
// returns while the user is simply scrolling, without
@@ -1672,7 +1746,7 @@ fn drain_thumbnails(
}
}
ThumbnailMessage::Unavailable { row, reason } => {
w.set_library_thumbs_done(w.get_library_thumbs_done() + 1);
job.advance();
log::debug!("thumbnail {row}: {reason}");
if let Some(mut r) = model.row_data(row) {
r.unavailable = true;
@@ -1682,18 +1756,16 @@ fn drain_thumbnails(
// TRACES: FR-CAT-9
ThumbnailMessage::Offline { reason } => {
log::info!("thumbnails stopped: {reason}");
// The batch is over, so the bar must not be left
// showing a partial fetch that will never finish — it
// would sweep for ever.
job.fail(reason.clone());
ctl_cb
.reachability
.borrow_mut()
.mark_unreachable(reason, std::time::Instant::now());
refresh_offline(&w, &ctl_cb);
// The batch is over, so the progress counter must not
// be left showing a partial fetch that will never
// finish — it would spin in the header for ever.
w.set_library_thumbs_total(0);
w.set_library_thumbs_done(0);
// Cells left without pixels stay placeholders rather
// than being marked unavailable: the images are fine,
// and a reconnect should fill them in. Marking them
@@ -1784,6 +1856,12 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The sync reports stages rather than counts, so it stays indeterminate and
// says what stage it is in.
let job = ctl
.activity
.begin(crate::activity::Kind::Sync, "Syncing with the server");
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
@@ -1795,6 +1873,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_syncing(false);
job.fail("stopped without finishing");
stop(&ctl_cb.sync_timer);
return;
}
@@ -1802,6 +1881,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
match msg {
crate::derived_sync::SyncMessage::Status(s) => {
job.detail(s.clone());
w.set_library_status(s.into());
}
crate::derived_sync::SyncMessage::Finished(report) => {
@@ -1823,14 +1903,17 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
);
w.set_library_syncing(false);
let summary = format!(
"{} shard(s) up, {} down",
report.shards_uploaded, report.shards_downloaded
);
job.finish(if report.did_anything() {
summary.clone()
} else {
"nothing to exchange".to_string()
});
if report.did_anything() {
w.set_library_status(
format!(
"synced · {} shard(s) up, {} down",
report.shards_uploaded, report.shards_downloaded
)
.into(),
);
w.set_library_status(format!("synced · {summary}").into());
}
// Adopted thumbnails and merged collections both change
// what the grid should show.
@@ -1842,6 +1925,7 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
crate::derived_sync::SyncMessage::Failed(e) => {
log::warn!("sync failed: {e}");
job.fail(e.to_string());
w.set_library_syncing(false);
// Not an error banner: a failed sync costs nothing —
// everything is still local and the next pass retries.
@@ -1888,6 +1972,13 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// Hours on a large library, and entirely invisible outside the grid until
// now: the register is where a user who has gone to develop can still see
// that indexing is running and how far it has got.
let job = ctl
.activity
.begin(crate::activity::Kind::Index, "Indexing capture times");
timer.start(
slint::TimerMode::Repeated,
// Slower than the thumbnail drain: this runs for tens of minutes and
@@ -1902,6 +1993,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_sweep_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.sweep_timer);
return;
}
@@ -1911,9 +2003,11 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
library::SweepMessage::Total(n) => {
w.set_library_sweep_total(n as i32);
w.set_library_sweep_done(0);
job.total(n);
}
library::SweepMessage::Progress { done, dated } => {
w.set_library_sweep_done(done as i32);
job.progress(done, w.get_library_sweep_total() as usize);
// Rebuild as it goes: the histogram growing while the
// sweep runs is the visible sign it is working.
if dated > 0 {
@@ -1925,6 +2019,7 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
}
library::SweepMessage::Finished { dated } => {
log::info!("sweep finished: {dated} dated");
job.finish(format!("{dated} dated"));
w.set_library_sweep_total(0);
{
let borrow = ctl_cb.catalog.borrow();
@@ -3261,7 +3356,7 @@ mod tests {
/// the *current* batch's timer and leaves the new fetches undrained.
#[test]
fn a_reload_makes_an_in_flight_thumbnail_batch_stale() {
let ctl = LibraryController::new();
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
// What `drain_thumbnails` captures when the batch is spawned.
let mine = ctl.generation.get();
@@ -3280,7 +3375,7 @@ mod tests {
/// Each load is distinct, so two reloads cannot alias back to a live batch.
#[test]
fn every_window_load_takes_a_fresh_generation() {
let ctl = LibraryController::new();
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
let seen: Vec<u64> = (0..4)
.map(|_| {
let g = ctl.generation.get();