Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the timeline scrub marker correction, the 423-Locked retry in the metadata sweep, and the thumbnail size-class migration. # Offline mode (FR-CAT-9) The app previously assumed the server was reachable and treated its absence as a series of unrelated per-operation failures. A launch without a connection produced an empty grid, even with a complete catalog on disk and every thumbnail already in the shards. Reachability is now inferred from traffic the app was already making, rather than probed for. `RemoteError::indicates_offline` draws the line that makes this possible: a dead connection is offline, a 403 or a 500 is not — the server answered, so blanking the library over one forbidden file would be a worse error than the one being reported. `Reachability` turns those outcomes into a state, so a library browsing happily never issues a probe at all. Going offline takes one failure, because the user is already experiencing it. Coming back requires evidence — a completed scan or a fetched thumbnail — with a capped exponential backoff behind the manual retry, so twelve sweep lanes failing together do not schedule twelve immediate probes. What keeps working: the catalog opens even when the scan that normally provides it failed, so the grid fills from the last successful scan. Thumbnails come from the shards. Rating, flagging and collecting are catalog writes that never touched the network. What stops is opening an original that was never stored locally, and it now says so in those words instead of reporting "network error: connection refused" over a photograph. Work that is pure network is refused rather than left to fail slowly: the metadata sweep, derived sync, and sidecar writes. The sweep would otherwise spend a timeout per image across the whole library while the progress bar implied something was happening. Deferring sidecars is a real gap rather than a hidden one — a rating made offline reaches its sidecar only when that image is judged again while connected — and it is recorded as such at the call site. # The "On this device" filter A chip beside the rating filters, narrowing the grid to images whose original is held locally. It composes with the rating terms rather than replacing them, so "five-star frames I can actually edit on this train" is one filter. The predicate is SQL, like the rating terms and for the same reason: the count in the header has to agree with the cells drawn. It reads `image_cache.tier_actual`, which nothing writes yet — the next commit fills it. Until then the chip honestly reports zero. `Tier` gains an explicit on-disk encoding. The variants are ordered by generosity and the derived `Ord` invites reordering them, which would silently reinterpret every cached row; the round-trip test is what holds the two in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -636,6 +636,21 @@ export component LibraryGrid inherits Rectangle {
|
||||
callback filter-unjudged-toggled(bool);
|
||||
callback filter-flag-changed(int);
|
||||
|
||||
// --- offline (FR-CAT-9) -------------------------------------------------
|
||||
//
|
||||
// Offline is a banner rather than a modal or an empty state, because most
|
||||
// of the library still works: the shards hold the thumbnails, and rating,
|
||||
// flagging, sorting and collecting are catalog writes that never touched
|
||||
// the network. Only opening an un-cached original actually fails.
|
||||
in property <bool> offline: false;
|
||||
in property <string> offline-reason: "";
|
||||
in property <string> offline-since: "";
|
||||
callback retry-connection();
|
||||
/// Narrow to images whose RAW is stored locally — the ones openable now.
|
||||
in property <bool> local-only: false;
|
||||
in property <int> local-count: 0;
|
||||
callback toggle-local-only();
|
||||
|
||||
/// How many images are selected, for the header's count.
|
||||
in property <int> selected-count: 0;
|
||||
/// Which collection scopes the grid, for the header. Empty means all.
|
||||
@@ -850,6 +865,21 @@ export component LibraryGrid inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { width: Theme.gap; }
|
||||
|
||||
// Locally-stored originals. Always offered, not only when
|
||||
// offline: "what can I actually work on right now" is a fair
|
||||
// question on a slow connection too, and a control that
|
||||
// appears only in the failure case is one the user has to
|
||||
// discover at the worst moment.
|
||||
FilterChip {
|
||||
label: "On this device";
|
||||
count: root.local-count;
|
||||
active: root.local-only;
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.toggle-local-only(); }
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
// What the filter is currently hiding. Without this a narrowed
|
||||
@@ -857,7 +887,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// single most confusing state a filter can leave behind.
|
||||
Caption {
|
||||
text: (root.filter-min-rating > 0 || root.filter-unjudged
|
||||
|| root.filter-flag > 0)
|
||||
|| root.filter-flag > 0 || root.local-only)
|
||||
? "filtered" : "";
|
||||
emphasised: true;
|
||||
vertical-alignment: center;
|
||||
@@ -886,8 +916,55 @@ export component LibraryGrid inherits Rectangle {
|
||||
: 0;
|
||||
}
|
||||
|
||||
// --- offline ------------------------------------------------------
|
||||
//
|
||||
// Above the scan error, and it suppresses it: when the server is
|
||||
// unreachable the scan failure is a *consequence*, and showing both
|
||||
// reports one problem twice while implying two.
|
||||
if root.offline: Rectangle {
|
||||
height: 34px;
|
||||
background: Theme.surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Caption {
|
||||
text: "Offline — showing what is stored on this device"
|
||||
+ (root.offline-since != "" ? " (" + root.offline-since + ")" : "");
|
||||
warn: true;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// The transport's own words. Usually specific enough to act on
|
||||
// — "connection refused" and "dns error" send the user to
|
||||
// different places — where a bare "offline" leaves them
|
||||
// guessing whether it is their wifi or the server.
|
||||
Caption {
|
||||
text: root.offline-reason;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Retry";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.retry-connection(); }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
|
||||
// --- error --------------------------------------------------------
|
||||
if root.scan-error != "": Rectangle {
|
||||
if root.scan-error != "" && !root.offline: Rectangle {
|
||||
height: 34px;
|
||||
background: Theme.surface;
|
||||
Caption {
|
||||
@@ -1064,14 +1141,24 @@ export component LibraryGrid inherits Rectangle {
|
||||
// *loaded window* while the viewport stays where it was, so
|
||||
// the cells are drawn thousands of rows away and the grid
|
||||
// looks empty until the user scrolls to find them.
|
||||
property <int> token: root.scroll-token;
|
||||
changed token => {
|
||||
function seek() {
|
||||
self.viewport-y = -min(
|
||||
max(0px, self.viewport-height - self.height),
|
||||
floor(root.scroll-to / max(1, root.columns))
|
||||
* (root.cell-size + Theme.gap));
|
||||
}
|
||||
|
||||
property <int> token: root.scroll-token;
|
||||
changed token => { self.seek(); }
|
||||
|
||||
// Also on creation, which is what returning from the develop
|
||||
// view needs. `show-library` gates an `if`, so the grid is built
|
||||
// anew and `token` is *initialised* to the already-bumped value
|
||||
// rather than changing to it — no `changed` handler fires, and
|
||||
// without this the restored position would be dropped and the
|
||||
// view would sit at the top.
|
||||
init => { self.seek(); }
|
||||
|
||||
// Sized to the **whole library**, not the loaded window. The
|
||||
// scrollbar has to represent 23,971 images or there is no way to
|
||||
// reach image 20,000 — dragging it must be a real address, and the
|
||||
|
||||
Reference in New Issue
Block a user