Keep originals on this device, by pin and by use
Build and test / Desktop (Linux) (push) Failing after 1s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 2s

Fills in `image_cache`, which the previous commit's "On this device" filter
read but nothing wrote. Also carries in-flight work that shared these files:
the Android TLS root store, the settings page, and a regenerated
traceability report.

# Two populations, deliberately separate

An original is kept here for one of two reasons, and conflating them produces
the exact failure the feature exists to prevent.

**Pinned** originals were asked for. Pinning a collection before a trip is a
promise, so pinned rows are never evicted and never counted against the
budget — a cap that could silently delete a pinned trip would make pinning
worthless, because it could not be relied on without checking.

**Passively cached** originals are a side effect of working: develop already
downloads the whole file, so keeping it costs no bandwidth and saves the
entire transfer next time. This population is what the budget bounds, evicted
least-recently-used, because it otherwise grows until a day of culling fills
a disk.

Sharing one budget would let a large pin starve the passive cache, or let
browsing evict a pin. They are separate.

# What was built

`dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned
— and writes the bytes; deciding to download stays with the caller, which is
what keeps a crate with no network out of the network's business. Files are
written to a temporary and renamed, so a dropped connection cannot leave a
truncated file recorded as a complete original. They are named by image id,
not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different
photographs, and a flat cache keyed on the name would serve one for the other.

`spawn_full_fetch` became read-through. A hit is a disk read; a miss stores
what it downloads and enforces the budget. A cache that cannot be opened is a
miss, not a failure to open the photograph.

Pinning writes intent — `tier_desired` — without downloading, so the button
responds immediately, and `spawn_pin_fetch` fills it in sequentially
afterwards. Sequential because these are tens of megabytes each: the lanes
that make the thumbnail sweep fast buy little against one connection's
bandwidth and cost a great deal of memory. A pin interrupted by a lost
connection resumes from where it stopped.

Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something
inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot
answer for an image whose rule was deleted. A v4 catalog migrates in place;
existing rows default to unpinned, the safe direction.

The budget and "keep opened originals" come from the settings page rather than
a constant, and are applied at startup rather than only on change — a cache
capped at 2 GB last session would otherwise spend this one filling to the
default. Turning off keeping leaves what is already cached readable: those
bytes are paid for, and refusing them would re-download images sitting right
there, including pinned ones.

Also removes a doubled `#[test]` introduced in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 21:12:01 +02:00
co-authored by Claude Opus 5
parent cd75e5a4c6
commit fa12afed18
22 changed files with 4032 additions and 86 deletions
+68
View File
@@ -583,6 +583,8 @@ export component LibraryGrid inherits Rectangle {
callback rescan();
/// Back to the launch screen, to change library or account.
callback change-library();
/// Open the settings page.
callback open-settings();
// --- selection and drag ---
//
@@ -646,6 +648,17 @@ export component LibraryGrid inherits Rectangle {
in property <string> offline-reason: "";
in property <string> offline-since: "";
callback retry-connection();
// --- pinning (FR-NC-6a) -----------------------------------------------
//
// Whether the collection the grid is scoped to is kept offline, and how
// far the download has got. Progress is shown because pinning a trip is
// gigabytes of transfer — a button that appeared to do nothing for twenty
// minutes would read as broken.
in property <bool> scope-pinned: false;
in property <int> pin-done: 0;
in property <int> pin-total: 0;
callback toggle-pin-scope();
/// Narrow to images whose RAW is stored locally — the ones openable now.
in property <bool> local-only: false;
in property <int> local-count: 0;
@@ -767,6 +780,19 @@ export component LibraryGrid inherits Rectangle {
clicked => { root.change-library(); }
}
// TRACES: FR-NC-6a
// Keep this collection offline. Only offered when the grid is
// scoped to one: "pin the whole library" is a different and
// much more expensive request, and a button that meant either
// depending on invisible state would be a trap.
if root.scope-label != "": Button {
text: root.scope-pinned ? "Pinned ✓" : "Pin offline";
active: root.scope-pinned;
enabled: !root.scanning;
y: (parent.height - self.height) / 2;
clicked => { root.toggle-pin-scope(); }
}
Button {
// Shares the finished index so a second device inherits it
// rather than repeating hours of range fetches.
@@ -782,6 +808,16 @@ export component LibraryGrid inherits Rectangle {
visible: !root.scanning;
clicked => { root.rescan(); }
}
// Last in the row, and unconditional. The buttons before it
// come and go with what the grid is showing; settings is
// always reachable, and a control that moved as its
// neighbours appeared would be hunted for each time.
Button {
text: "Settings";
y: (parent.height - self.height) / 2;
clicked => { root.open-settings(); }
}
}
}
@@ -916,6 +952,38 @@ export component LibraryGrid inherits Rectangle {
: 0;
}
// --- pinning ------------------------------------------------------
//
// Its own line rather than the shared progress bar above: that one is
// driven by the scan and the sweep, and a pin runs alongside both.
if root.pin-total > 0: Rectangle {
height: 34px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Caption {
text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total;
vertical-alignment: center;
}
ProgressBar {
fraction: root.pin-done / max(1, root.pin-total);
y: (parent.height - self.height) / 2;
horizontal-stretch: 1;
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// --- offline ------------------------------------------------------
//
// Above the scan error, and it suppresses it: when the server is