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:
+123
-28
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user