A terminal browser that can hold twenty years - #2
Merged
Conversation
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]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
gjumps 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, andhome/endreach 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/AssetFilterin 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 2011onlist/searchhid the whole year.imogen timeline --before 2011compared raw strings, and"2011-08-15" <= "2011"is false, so a bare year or month hid everything inside it. Both bounds now widen throughdates::before comparing.rebuild_windowplaced 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 warningsclean,fmt --checkclean.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