Merge master into tablet-selection

Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:52:42 +02:00
co-authored by Claude Opus 5
54 changed files with 7566 additions and 166 deletions
+164 -20
View File
@@ -270,6 +270,22 @@ pub struct LibraryController {
/// judgement, and carrying the old one over would report a server down
/// that was never contacted.
reachability: RefCell<dr_sync::Reachability>,
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// Why the library folder itself could not be opened, if it could not.
///
/// Beside [`Self::reachability`] rather than inside it, because
/// `dr_sync::Reachability` models *the server*, and it is deliberately
/// unmoved by a refusal — a forbidden file must not report the network as
/// down (see its own tests). A revoked tree grant is a refusal, so folding
/// it in would either break that rule or need an exception carved through
/// it.
///
/// Set only by a scan that failed at the root, and cleared only by one
/// that succeeded. Both states drive the same banner as being offline
/// does, because what the user can do is the same — carry on with what is
/// stored on the device — but the sentence under it is different, and so
/// is what will end it.
root_lost: RefCell<Option<String>>,
/// TRACES: FR-NC-6a
/// Drains the pin downloader. Held so a second pin replaces the timer
/// rather than leaving two draining the same finished channel.
@@ -372,6 +388,7 @@ impl LibraryController {
sidecar_timer: RefCell::new(None),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
root_lost: RefCell::new(None),
outbox_timer: RefCell::new(None),
outbox_maybe_dirty: std::cell::Cell::new(true),
geometry_timer: RefCell::new(None),
@@ -443,10 +460,15 @@ impl LibraryController {
}
}
/// TRACES: FR-CAT-9
/// Whether the app currently believes the server is unreachable.
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
/// Whether the library cannot be reached, for either of the two reasons.
///
/// One answer rather than two because every caller asks it for the same
/// purpose: to decide whether starting a transfer is worth attempting.
/// A revoked grant fails that question exactly as a dead network does, and
/// a sync started against it would spend its retries proving it.
pub fn is_offline(&self) -> bool {
self.reachability.borrow().is_offline()
self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some()
}
/// Whether the grid is narrowed to locally-stored originals.
@@ -941,6 +963,17 @@ fn drain_scan(
{
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);
// An incremental rescan lists almost nothing, so
@@ -995,7 +1028,11 @@ fn drain_scan(
stop(&ctl.scan_timer);
return;
}
ScanMessage::Failed { message, offline } => {
ScanMessage::Failed {
message,
offline,
lost_root,
} => {
log::warn!("scan failed: {message}");
w.set_library_scanning(false);
// Recorded as a failure even where it is only the
@@ -1004,7 +1041,26 @@ fn drain_scan(
// stopped because of it.
job.fail(message.clone());
if offline {
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
@@ -1585,17 +1641,35 @@ fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
/// which is what keeps it testable without a display server.
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
let reach = ctl.reachability.borrow();
let offline = reach.is_offline();
// TRACES: FR-PLAT-AND-2
// A lost root wins over a dead network, and does so even when both are
// true — which is the ordinary case, since the scan that discovered the
// grant was gone was also the last request the app made. Reported the
// other way round the user is told to wait for a connection that is
// working, and the thing that would actually fix it is never mentioned.
let lost = ctl.root_lost.borrow();
let offline = reach.is_offline() || lost.is_some();
window.set_library_offline(offline);
window.set_library_offline_reason(reach.reason().unwrap_or_default().into());
window.set_library_offline_reason(match lost.as_deref() {
Some(why) => why.into(),
None => reach.reason().unwrap_or_default().into(),
});
window.set_library_offline_since(
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into(),
// A duration is what a network outage has and a revoked permission
// does not: "for 4 minutes" invites waiting, and waiting is precisely
// what will not help here.
if lost.is_some() {
slint::SharedString::default()
} else {
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into()
},
);
drop(lost);
// A stale scan error under an offline banner reports one problem twice.
if offline {
@@ -2214,6 +2288,11 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// window, not one per cell.
rating: 0,
flag: 0,
// And by `bursts::sync_badges`, in one more query for the
// window. Zero is "not in a burst", which is what almost every
// photograph in a library is.
burst_count: 0,
burst_expanded: false,
}
})
.collect();
@@ -2246,6 +2325,9 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
.collect();
crate::collections_ui::sync_badges(window, catalog, &ids);
sync_ratings(window, catalog, &ids);
// How many frames each cell stands for, where it stands for several
// (FR-CULL-5).
crate::bursts::sync_badges(window, catalog, &ids);
// The rebuilt cells all carry `selected: false`, but the selection itself
// is a set of image ids and survives untouched. Without this the ticks
// vanished on every scroll — the selection was still there and still acted
@@ -3769,6 +3851,28 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
if !offline {
start_derived_sync(&w, &ctl_cb);
}
// And now every photograph in the library has a
// thumbnail, which is the only moment all of its burst
// signatures can be computed. See `bursts::start_pass`,
// which owns the pass and everything it needs to drain
// itself; what it wants from here is the paths and a
// way to say the grid has something new to draw.
if let Some((conn, _)) = ctl_cb.session.borrow().clone() {
let weak_after = w.as_weak();
let ctl_after = ctl_cb.clone();
crate::bursts::start_pass(
library::catalog_path(&conn.account),
library::thumbs_dir(&conn.account),
move |bursts| {
let Some(w) = weak_after.upgrade() else {
return;
};
if bursts > 0 && w.get_show_library() {
schedule_reload(&w, &ctl_after);
}
},
);
}
return;
}
}
@@ -4119,10 +4223,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
catalog
.connection()
.query_row(
"SELECT captured_at FROM images
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
&format!(
"SELECT captured_at FROM images
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
AND {}
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
dr_catalog::bursts::not_collapsed_away("images")
),
[ordinal as i64],
|r| r.get::<_, i64>(0),
)
@@ -4153,13 +4261,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// BY), so they never precede a dated one and the predicate below stays
// a simple `<`. Shadowed rows are excluded here exactly as the grid
// excludes them.
//
// A burst folded up occupies one row of the grid, so it must occupy one
// row of this count as well: an ordinal taken over the unfolded library
// would overshoot by every frame hidden earlier in it.
catalog
.connection()
.query_row(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1",
&format!(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1
AND {}",
dr_catalog::bursts::not_collapsed_away("images")
),
[when],
|r| r.get::<_, i64>(0),
)
@@ -4953,6 +5069,34 @@ pub fn wire<F>(
});
}
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_burst_toggled(move |row| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
let changed = {
let borrow = ctl.catalog.borrow();
match borrow.as_ref() {
Some(catalog) => crate::bursts::toggle(catalog, id),
None => false,
}
};
if changed {
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
}
});
}
// A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty.
{