Skip to content

A terminal browser that can hold twenty years - #2

Merged
ergofobe merged 6 commits into
mainfrom
timeline-scrubbing
Aug 27, 2026
Merged

A terminal browser that can hold twenty years#2
ergofobe merged 6 commits into
mainfrom
timeline-scrubbing

Conversation

@ergofobe

Copy link
Copy Markdown
Owner

The browser kept every asset it had paged in and every thumbnail it had ever decoded — the second in a map with no eviction at all, holding decoded images. On a twenty-year library that is a leak with a picture in it, and there was no way to reach 2009 except paging there from today.

Depends on ergofobe/imogen-sdk#2, which must merge first.

What changed

It now learns the timeline's shape from the day buckets, holds only the tiles the viewport covers, and caps the decoded-thumbnail cache the way the preview cache was already capped. g jumps to a date — "aug 2011", "2011-08-14", or "2011" — landing on the nearest day that has photographs when the one asked for has none. A year rail down the right edge says where you are, [ and ] step whole years, and home/end reach the ends.

The terminal's geometry is uniform tiles, so none of the web client's height estimation, measured-versus-estimated distinction or scroll compensation is here — a global index maps to a row by division. Those concepts solve a problem this grid does not have.

Worth knowing for review

The branch point did not compile. The SDK's move to AssetSelection/AssetFilter in an earlier phase broke this repo immediately, because Cargo path dependencies are live. That is fixed in its own commit first, so the task itself starts from something testable.

Three bugs found along the way, none of them in scope:

  • to_end_of_day("2011") returned 1 January, so --before 2011 on list/search hid the whole year.
  • imogen timeline --before 2011 compared raw strings, and "2011-08-15" <= "2011" is false, so a bare year or month hid everything inside it. Both bounds now widen through dates:: before comparing.
  • rebuild_window placed tiles at the spine's index but took as many as the period held, so a period holding more than the spine says would spill into the next period's indices — reachable whenever a new spine arrives over tiles filed against an older one. The spine is now authoritative for how many photographs a period contributes.

Two guards against showing the wrong photographs. An epoch stamped into spine and bucket answers, so a reply issued under one scope cannot be filed after a reset. And a page is filed only if it answers the cursor the period is currently asking from — so a continuation arriving into a forgotten period is dropped rather than filed as a first page, which would have put the third photograph at August's first index and never re-fetched.

Eviction clears the wanted-set. A thumbnail evicted while still marked wanted is never re-requested and stays permanently blank, which is worse than the leak it replaced.

Verification

101 tests, clippy --all-targets -D warnings clean, fmt --check clean.

Each fix was proven by reverting it and watching the specific test fail, and where a wrong-but-tempting implementation exists it is tested too — clearing the in-flight mark inside the forget looks like tidier code and strands the period forever, so there is a test that fails on exactly that. One mutation survived its first pass and is disclosed in the record rather than counted as a clean sweep.

Not exercised: the pagination path against a real server. A period over the server's 5,000 default has only ever been driven by unit tests, and that is where the residual risk sits.

Spec: imogen-server/docs/superpowers/specs/2026-08-27-timeline-scrubbing-design.md

🤖 Generated with Claude Code

https://claude.ai/code/session_015MF3M6T94djHJmr5neeZkv

The Rust SDK on this branch takes an `AssetSelection` where it used to take a
list of ids, asks for a `TimelineQuery` where `timeline()` took nothing, and
has grown a `person_id` on `AssetQuery`. None of that is a decision this crate
gets to make, and until it followed, the browser did not compile at all —
which would have made the rest of this task impossible to even test.

Co-Authored-By: Claude with claude-opus-5[1m]
The browser kept every asset it had paged in and every thumbnail it had ever
decoded, the second of those in a map that was never evicted from. On a
twenty-year library that is a leak with a picture in it. And there was no way
to reach 2009 except paging back from today, a screen at a time.

It now learns the shape of the timeline from the day buckets — one entry a
day, small enough to hold for twenty years — and holds only the tiles the
viewport covers, dropping the periods it has scrolled away from. Decoded
thumbnails are capped the way previews already were, and eviction also forgets
that a picture was ever asked for: dropping the image without dropping that
record would leave a tile that could never be filled again.

`g` jumps to a date, landing on the nearest day that has photographs when the
one asked for has none. It walks the sorted buckets rather than the calendar,
so a gap of eleven years costs exactly what a gap of two days costs — the web
version of this searched month by month, gave up, and threw the reader at the
end of the library. `[` and `]` step whole years the same way, through the
years the library actually has. A rail down the right edge says where in the
twenty years you are, which a screen of tiles cannot.

The viewer and the details panel still need a whole record, so one is read for
the photograph being looked at and no others; a tile carries only what the
grid draws.

Co-Authored-By: Claude with claude-opus-5[1m]
Nothing drains the in-flight requests when the scope changes, so a bucket
fetched under the library's filter could land after the trash's spine had
replaced it. Filed, it put library photographs at trash indices — the wrong
pictures, at plausible places, with nothing on screen to say so. Worse, the
period then counted as held whole, so the correct tiles were never asked for
and the wrong ones stayed until the viewport moved off. Every request now
carries the epoch it was issued under, and an answer stamped with a stale one
is dropped before it touches anything — including the in-flight mark, which
belongs to the request the current results have out for the same period.

`imogen timeline --before 2011` also hid all of 2011. It compared bucket dates
against the bound as raw strings, and "2011-08-15" <= "2011" is false. The
bounds now widen the way every other filter's do, so a bound less than a whole
day means the whole of that period.

And the thumbnail cache is arrival-ordered, which for a grid is already
recency — thumbnails arrive as they scroll into view. It said it was doing
something cleverer than that and was not.

Co-Authored-By: Claude with claude-opus-5[1m]
A month over the server's five-thousand default arrives in more than one
answer, and a scanned-archive import can land forty thousand photographs on
one date. Scroll away from such a month mid-fetch and its second page came
back to a period the viewport had already forgotten — where, appended to
nothing, it became that period's first page: the third photograph of August
drawn at August's first index, the month then counting as held whole and so
never asked for again. Wrong photographs at plausible places, silently and
permanently, reached without any scope change, so the epoch could not see it.

A period's tiles are only meaningful as a complete prefix from its first page,
so the two kinds of page are not interchangeable. A first page always answers
something — it starts the prefix. A continuation answers "what follows this
cursor", which is only a question while the period is still asking it, and
`period_more` is already where that question is written down. So a page now
carries the cursor it was asked from, and is filed only when that matches what
the period wants. A dropped page leaves the period with no tiles and no
cursor, which is exactly what "not held" looks like, so the next pass asks
again from the first page.

The forget still leaves `period_inflight` alone. That is what keeps at most
one request out per period, which is in turn what lets an answer clear the
mark without having to wonder whose request it belongs to. Clearing it there
would un-strand the period by asking for it twice.

Co-Authored-By: Claude with claude-opus-5[1m]
`rebuild_window` laid each held period's tiles down at the index the spine
gave that period, but took as many tiles as the period happened to be holding.
A period holding more than its bucket says therefore ran into the next
period's indices: September's third photograph drawn under August's first,
and everything after it one place along. That is reachable — a new spine is
absorbed over tiles that were filed against the old one, so a period that
shrank between the two leaves the window holding more than it has room for.

The spine defines every index in the browser: `total`, `date_at_index`,
`index_for_date` and `period_start` all derive from it. So a period
contributes at most the photographs the spine says it has, and the window is
a view onto spine-defined indices rather than a concatenation that happens to
start in the right place.

Pinned exhaustively rather than by example: over every combination of which
periods are held, any tile the window answers with must be the tile at that
global index, and must fall on the day `date_at_index` independently says
that index falls on.

Co-Authored-By: Claude with claude-opus-5[1m]
The server caps `AlbumWithAssets.assets` and `PersonWithPhotos.photos` at
sixty now — both became a cover sample, and the grid was moved to paging the
timeline under an `albumId` or `personId` filter. The CLI was not, so
`imogen album show holidays --ids` printed sixty ids where it used to print
every one, and `imogen album show holidays --ids | xargs imogen trash`
trashed sixty of thirty thousand photographs without saying so. The header
made it worse rather than better: `photographs: 30000` sat directly above
sixty rows.

So both commands walk `GET /assets/timeline/bucket` instead — the day
buckets say which months exist, and each month is followed by its cursor to
the end. Nothing caps or limits: a list feeding `xargs` is either all of it
or it is wrong.

The header now counts what was actually fetched, so the number above the
rows is the number of rows. For a person it also stops calling faces
photographs: `photoCount` on the wire is the server's `faceCount`, which
counts faces, so one photograph holding two of this person's faces counted
twice. It is printed as "faces" beside a "photographs" count that is the
rows, and the list column is headed FACES for the same reason.

A tile is not an `Asset`. The bucket endpoint returns the lean projection,
which carries no filename, no size, and no archived or trashed mark, so
those columns are dropped from these two tables rather than printed empty —
filling them would mean one request per photograph. `imogen ls --album
<name> --all` still walks `GET /assets` and prints the full row.

The JSON changes shape with it: `album show` now emits
`{album, items, count}` and `people show` `{person, items, count,
faceCount}`, where `items` are tiles rather than assets. The old shape
flattened the record and hung a capped `assets`/`photos` array off it, so
nothing that read it was reading the truth anyway.

Co-Authored-By: Claude with claude-opus-5[1m]
@ergofobe
ergofobe merged commit 4ed2aa6 into main Aug 27, 2026
2 checks passed
@ergofobe
ergofobe deleted the timeline-scrubbing branch August 28, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant