Skip to content

Time-travel: scrub the repo's history and watch the city grow#125

Merged
thalida merged 137 commits into
mainfrom
feat/issue-113-time-travel
Jul 24, 2026
Merged

Time-travel: scrub the repo's history and watch the city grow#125
thalida merged 137 commits into
mainfrom
feat/issue-113-time-travel

Conversation

@thalida

@thalida thalida commented Jul 24, 2026

Copy link
Copy Markdown
Owner

A Timeline mode that replays the repo's whole git history as the city being built, aged, and torn down.

Flip the Live/Timeline toggle on the scene and drag the scrubber. Buildings rise as files are added, weather and crumble into ruins as they're deleted, and files that don't exist yet at the scrubbed commit show as faint tinted slabs. The full history loads once as a union city, then scrubs entirely client-side, so dragging stays smooth on large repos.

Closes #113.

What you get

  • Live/Timeline toggle on the scene, with a time-based scrubber whose label is gap-aware (it reads the actual commit dates, not an even spread).
  • Growth + decay: each building's height and age-lean track the file at the scrubbed commit. Deleted files become ruins (roof and four walls, broken via holes and a crumbled-top shader) that weather by age; deleted roads recolor too.
  • Future files render as ultra-low tinted slabs rather than ghosts, so the eventual shape of the city is legible without competing with what exists yet.
  • Sidebars stay useful while scrubbing: selecting a building/road opens the right sidebar; the left-sidebar tree and search span the full history and mark each path deleted/future relative to the scrub; the file preview shows a deleted state when the file is gone at HEAD.
  • Excludes apply to Timeline too: an excluded path is filtered out of the union scene, so it never appears as a building, a tree row, or a search hit.

Notable implementation notes

  • The history walk produces per-commit blob deltas once; the client replays deltas[0..i] to reconstruct any commit's file set, and the replay is tied to the same reconstruction the scan path already proved (tests assert they match at every commit).
  • The union city is packed a single time through the shared tree builder, so its layout is bit-identical to every per-commit reconstruction it's scrubbed against. Per-frame the scrub controller owns each building's scaleY + fade and each street's opacity.
  • Pathological repos are windowed to their most recent commits (union-file cap), surfaced via a note on the bundle.
  • Timeline bundles are cached per HEAD (and per exclude set), immutable, cleared alongside the manifest caches.
  • Cancelling any loading overlay returns you to the view you came from rather than the project switcher.

Testing

Backend unit + TestClient coverage for the delta walk, union manifest, replay-matches-reconstruction, the union cap, excludes filtering, and cache keying. Frontend vitest for the scrub controller, timeline mode enter/exit/cancel, the API layer, and the sidebar/preview states. Full pre-push gate (pytest, ruff, vitest, eslint, prettier, typecheck) green.

🤖 Generated with Claude Code

@thalida thalida linked an issue Jul 24, 2026 that may be closed by this pull request
thalida and others added 29 commits July 24, 2026 00:26
…blob stats)

Task 1 of #113: resolve_ref/ls_tree_files/blob_stats_batch in gitobj.py,
built on git ls-tree/cat-file/rev-parse only (no checkout/reset/worktree).
blob_stats_batch needs media dims without a filename to dispatch on, so
media.py gains a bytes-sibling probe (probe_media_dims_from_bytes) that
content-sniffs instead: PIL's own magic-byte detection for images, an
<svg>-root check for SVG (guards against mistaking arbitrary XML for one
now that there's no .svg extension to gate on), hachoir last for video.
Image/video internals are shared between the path and bytes entry points
(Image.open and createParser both accept a path or a file-like object) so
the disk probe keeps its lazy, non-buffering I/O; SVG's DOM parse has no
such distinction so it's fully shared.
blob_stats_batch ran hachoir's full format probe on every blob's bytes,
including plain source files — wasteful, and hachoir spams stderr with
"[warn] Skip parser ..." per rejected format. Gate the probe behind a new
media_shas keyword param so only blobs the caller identifies as media
(by extension, mirroring the live scanner's media_kind gate) get probed;
default frozenset() keeps existing callers/tests probing nothing.
Two review findings on Task 1's gitobj.py plumbing:

- scan.py had its own copy of the binary-detection heuristic
  (_BINARY_CHUNK_SIZE/_TEXT_CHARACTERS/_is_binary) duplicating
  gitobj.py's _is_binary_bytes. The two must stay in lockstep — they
  classify the same file content for the live scan and the time-travel
  reconstruction respectively — so scan._is_binary now reads the first
  chunk and delegates to gitobj.is_binary_bytes (the single source).
  Renamed _BINARY_CHUNK/_is_binary_bytes to unprefixed BINARY_CHUNK/
  is_binary_bytes since they're now an intentional cross-module
  contract (pyright strict flags underscore-prefixed cross-module
  access as reportPrivateUsage).
- gitobj.py repeated the `git -c safe.directory=* -C <root>` argv
  prefix in _git() and inline in blob_stats_batch's cat-file --batch
  call. Extracted _git_argv(root, *args) as the one place those
  safety flags live.
- cache_clear_all docstring now lists blob-stats among the caches it
  deletes (the delete loop already removed it, docs just lagged)
- cache_save_blobs docstring references the manifest-cache swallow
  policy specifically, since files/git-history let OSError propagate
- blob-cache roundtrip test now asserts media_height alongside
  media_width so both halves of the pair are verified
_collect_git_history/_collect_git_dates take an optional ref (a resolved
sha) so the git log walk starts there instead of implicit HEAD, giving
correct created/modified dates and commit list as of a past ref. The
git-history cache key generalizes head_sha -> commit_sha (any ref sha is
immutable, so caching on it is permanent); cache version bumped 12 -> 13
since the stored key name changed.
Split _build_tree's two filesystem touch-points into injected callables
(list_children, make_file_node) so a git-object reconstruction can drive
the identical frame/rollup/ordering loop. The live scan wires up FS
adapters that reuse the real os.DirEntry, keeping _file_node's stat/hash
byte-for-byte identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…objects

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-scan parity

ls_tree_files kept every ls-tree entry with git type "blob", but a committed
symlink is also mode 120000/type blob. The live scan drops symlinks entirely
(_live_list_children only keeps is_file/is_dir with follow_symlinks=False),
so a ref reconstruction with a committed symlink produced an extra file node
the live scan never shows, breaking the bit-identical structure/layout
invariant. Skip mode-120000 entries in ls_tree_files, and harden the
reconstruction guard test with a committed symlink so a regression here is
caught by the same test that pins structure/layout signature equality.
…cache

Exposes reconstruct_manifest over the existing SSE manifest route so a
client can request the tree as of a past commit. A ref's manifest is
immutable, so it gets its own permanent on-disk cache keyed by
(repo_key, ref_sha); the content-signature and ref manifest caches now
share gzip load/save helpers instead of duplicating the envelope logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds URL_PARAMS.REF (wire param the backend's ?ref= endpoint from
Task 6 reads), plumbs it through manifestUrlFor, and adds ref? to
SourcePayload so a time-travel jump can carry a commit sha end to end.
…oll suspend

Adds the view-state pin for viewing a repo at a past commit without
switching CURRENT_SOURCE, plus the load path (final-only, shares the
generation guard with loadSource) and exit path (clears the pin, then
reloads HEAD). The live-update poll and exclude-refresh reaction both
yield while pinned so they can't pull HEAD back in over the pinned view.
…ears

loadRef reused pumpManifestStream (which writes SCAN_PROGRESS) but never reset
it in finally like loadSource does, leaving the loading overlay stuck over the
pinned city. Mirror loadSource's authoritative-gen cleanup + add a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TimeTravelBar: a bottom-bar slider over the full (stable) HEAD commit
history, mounted in CenterPane. Settled drags call loadRef/exitTimeTravel;
axis is locked in per source so a shorter past-ref manifest never shrinks
the track. Also guards FilePreviewPane's fullPath fetch during time travel
(the endpoint reads disk, not the reconstructed ref).
…anifest cast)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
I1: TIME_TRAVEL_REF only got cleared by exitTimeTravel, but switching
repos goes through loadSource in place — the pin from repo A survived
onto repo B, wedging its poll/excludes reactions and file preview.
loadSource always loads a source's live HEAD, so clear the pin there.

M2: ReadmePane fetched the working copy unconditionally, unlike
FilePreviewPane's guard, so the InfoPane showed HEAD's README while
pinned to a past ref. Mirror FilePreviewPane's guard.
…ding overlay)

loadRef now streams the final directly like the live-update refresh instead of
via pumpManifestStream, so it never writes SCAN_PROGRESS: scrubbing no longer
pops the full-screen loading overlay, the scene tween carries the morph. The
slider tracks the thumb locally and debounces the load so a drag fires one
rebuild on settle, not one per step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stack the commit info under a full-width slider and give the bar a fixed width,
so the centered bar no longer re-centers (and the slider no longer jumps) as the
commit subject changes width while scrubbing. Subject truncates with ellipsis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadRef now marks REBUILD_STATUS=Rebuilding from the start, so the bottom-left
footer indicator shows 'rebuilding…' through the fetch (the slow part on a cold
ref) and the apply, instead of no feedback now that the overlay is gone. On a
failed ref load it marks the footer error rather than reopening the source
picker, matching the live-update refresh path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the oversized btn-secondary text button with the shared 22px btn-icon--sm
(a RotateCcw icon + Back to live tooltip), matching the app's other icon buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drop the drop-shadow; the bar is now dark semi-transparent with a backdrop
  blur of the scene behind it.
- Move the reset to the right of the slider (its own track row).
- Use the app's shared .setting-row-reset (RotateCcw), always shown: accent when
  time-traveling (can go back to live), disabled/faded when already live -
  matching how reset works on settings rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Glass fill derives from --cc-bg-modal (honors the theme surface) instead of a
pure-black wash. Tighten the slider-to-reset gap and the reset-to-right-edge
padding so the button sits snug at the edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t line

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Uses the shared commitUrl() helper (as CommitPane does). Falls back to plain
text when the source has no remote (a local repo without an origin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Symmetric bar padding + drop the reset's side padding so both edge icons are
bare glyphs equidistant from their edge and the slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thalida and others added 26 commits July 24, 2026 00:26
Auto-deselecting a scrubbed-away element mid-drag closed the right sidebar,
reflowing the scrubber track under the pointer and jumping the position to the
end. Gate the per-frame prune on a new SCRUB_DRAGGING signal (driven by the
TimeTravelBar's pointer down/up, replacing its local ref) so the deselect — and
the layout change it triggers — waits until the drag ends. Keyboard/edge-jump
scrubs aren't drags, so they still prune immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Track edits out of the box instead of requiring the user to enable polling.
(Only affects fresh state; a persisted LIVE_UPDATES keeps the user's choice.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ty" hint

The section lists only the user's browser-saved excludes; a reader can't tell
node_modules/.git/etc. are already skipped. Add a link to the README's
"Skipped by default" list. Section.hint widens from string to ComponentChildren
so it can carry the link (every existing string hint still works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rides

The timeline pose force-enabled RUINS and BLUEPRINTS so the shot showed every
representation, but that misrepresents the default experience — future files are
off by default. Drop both overrides; the shot now enters Timeline mode with no
settings layer of its own, so the README screenshot matches the defaults
(deleted stubs on, future files off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captured via `just screenshots timeline` — reflects the default settings
(deleted stubs on, future files off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stays HEAD

The file tree and search now read a new HISTORY_MANIFEST signal — the all-time
union manifest while scrubbing, the live HEAD manifest otherwise — so deleted
paths still appear and can be inspected in Timeline. The README pane instead
reads HEAD (MANIFEST) directly and no longer shows a "not available in time
travel" state: it fetches the current checkout, which is always HEAD. Removed
that now-dead InfoBodyKind.TimeTravel branch and its imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New HISTORY_NODE_STATE signal maps each union path to its state at the current
scrub position — present / deleted (ruin) / future (not yet created) — reusing
the replay timelines + ruinStateAt, with directories inheriting the strongest
descendant state (present > deleted > future), matching how a street's state is
decided from its buildings. The file tree and search results style deleted rows
faded + struck through and future rows faded + italic, so the sidebar tracks the
city as you scrub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking a deleted or not-yet-created path in the left-sidebar tree/search
selects it (right sidebar opens with its info / deleted state) even though the
city renders nothing for it at this commit. Previously the per-frame prune
cleared it instantly. The prune now fires only when the scrub actually MOVES —
so a selection removed BY scrubbing is still dropped (no dangling outline), but
a static explicit pick is kept until you scrub away from it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…off)

The left-sidebar tree + search now filter the union to the same set of paths the
city renders: deleted (ruin) paths drop out when "Show deleted files" is off, and
future paths drop out when "Show future files" is off — with emptied directories
pruned. Filtering lives in HISTORY_MANIFEST (a fast path returns the raw union
when both toggles are on), so the tree and search consume it unchanged.

Added a SCRUB_COMMIT signal (floor of SCRUB_POS): per-path state only changes at
integer commits, so the marking + filter recompute once per commit crossing
instead of every sub-commit interpolation frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…b commit

Reverses the earlier "show deleted/future, marked" approach: the file tree and
search now always filter the union to just the files alive at the scrubbed
commit — no deleted, no future — so they match exactly what the city renders,
regardless of the ruins/future toggles.

Replaces the 3-state HISTORY_NODE_STATE map (and its deleted/future row styling)
with a simpler PRESENT_PATHS set (present files + their ancestor dirs); the
history manifest filters to it. SCRUB_COMMIT keeps the recompute per commit, not
per frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…EADME

Extract the file/dir "content is HEAD, not the timeline commit" toast into a
shared <TimelineStaleNote> component (its own colocated CSS), and use it on the
README pane too: while scrubbing, the README floats a toast saying it shows HEAD,
not the README at the timeline commit. ReadmePane now owns a positioning-context
container with an inner scroller so the toast pins like the other panes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete 5 genuinely-dead exports (no production caller; verified via knip +
per-export grep, adversarially — most knip hits were over-exports or the uniform
*Config convention, left alone):
- scene.focusCurrentSelection — the F-key handler calls rig.focus* directly
- useSettings.useDiffersFromDefault — siblings used, this one never
- dates.dateMs, dates.humanAge — humanAge was test-only (removed its test too)
- shots.ShotName — unused type

Also un-export tierLevelFor/tierFromPrefix (fadeTiers) — used only internally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cache volume is compose-project-scoped (codecity-<slug>_codecity-cache), so
the old `docker volume rm codecity-cache` never touched a worktree's cache. Now
it resolves the slug, stops the dev stack to free the volume, and removes the
right one — a full reset of the clones + manifest/timeline/blob caches, which all
live in that single volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…live view

The loading overlay is shared by the cold-boot live load and the timeline enter,
but its Cancel was hardcoded to open the project list — so cancelling a timeline
load dropped you into the switcher (and the bundle kept loading in the
background). Now each load registers its own cancel via a LOADING_CANCEL signal:
timeline enter aborts the history SSE (fetchTimelineBundle gained an AbortSignal)
and stays on the live city, since the scene isn't repacked until after the fetch.
The live cold-boot load has no prior view, so it keeps the project-list fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l 5s

Cancelling the live-HEAD reload that runs when you leave Timeline dropped you on
the project switcher. loadSource resets Timeline and streams a skeleton into the
shared scene at its start, so "stay in Timeline" isn't possible — instead exit
registers a cancel that re-enters Timeline where you were (bundle is warm-cached,
scrub position restored). The registration survives the load reaction's overlay
because showLoadingOverlay now only sets the cancel handler when one is passed.

Also: default live-update poll 3s -> 5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leted, flip rebuilding instantly

- Exclude re-scans live HEAD, which Timeline's fixed union scene can't reflect,
  so hide the exclude button while scrubbing instead of offering a no-op.
- Excluding a file also drops it from the HEAD manifest, so the deleted-file
  detection (absent from HEAD) wrongly flagged excluded files as "deleted". It's
  still on disk + fetchable — subtract ACTIVE_EXCLUDES from the check.
- The footer only flipped to "rebuilding" once the exclude re-scan streamed back;
  mark it rebuilding when the refetch starts so it responds on click.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Show a centered spinner instead of an sr-only span while a file preview
streams, and mark the file-content fetches priority:'high' so the active
pane jumps ahead of background manifest/blob requests on big repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread the active excludes through /api/timeline the same way live scans
carry them: build_timeline_bundle folds them into the .codecityignore skip
filter, so an excluded path is absent from the union manifest, every delta,
and the blob tables. Excludes are part of the timeline cache key so a
filtered bundle never collides with the unfiltered one at the same HEAD.

The frontend sends activeExcludePathsFor(src) when entering Timeline. With
excluded files gone from the union, they can't be selected there, so the
RightSidebar's excluded-vs-deleted guard is now moot and removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A lone commit has no history to scrub. The date axis collapsed (span floored
to 1), so indexToFraction returned 0 and the handle stuck to the left edge.
Treat a single commit as the present: handle + tick + played-fill sit at the
right, and the track is inert (default cursor, no drag, out of the tab order)
instead of a grab-and-freeze slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pses

Commit dates are day-precision, so a repo whose whole history lands on one
calendar day has a zero time span: every commit mapped to fraction 0 and the
handle stuck to the left, unscrubbable. Detect the degenerate span and fall
back to even index spacing (oldest left, present right) so you can scrub all
the commits. The time-clustered axis still applies whenever dates spread.

Generalizes the earlier single-commit fix (n=1 is just the degenerate case
with one point → pinned to the present, inert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t set

Scrub height normalized floors against presentLineStats (the line range of
files present at that commit), so a building could shrink as its file grew and
a lone-present file collapsed to MIN_FLOORS (degenerate range). Use the fixed
union range (heightCtx.lineStats) instead, matching how width already uses the
union byteStats, so a building's height is a function of its own line count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…'s tree

A 2-commit repo sampled only ~8 grid cells; on a small island where the city
covers the center, most collide and accepted falls below the commit count,
dropping trees (HEAD's tree vanished). Floor the grid at TREE_MIN_CELLS so
every commit lands a tree; large repos are unaffected (treeTarget*4 exceeds it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dates)

Scrub color/age normalized against the present-set date range (correct: a scan
at commit N sees commit N's files), but read DAY-precision commit dates. At HEAD
those can't match live's FULL-precision file dates: same-day commits collapsed to
one instant, so every building painted at recency 1 and HEAD diverged from live.

Prefer each file's own modified/created date once it has reached its final (HEAD)
state; fall back to the commit day-date for earlier history (all that's available)
and for fixtures without dates. Keeps the per-scrub present-set normalization, so
scrubbing still re-weathers each commit, but HEAD now equals the live view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…red fixture

test_blob_stats_batch_media_probe_gated_by_extension read logo.png from
tests/fixtures/sample-repo/, which is gitignored — present locally but absent on
a fresh CI checkout, so the job failed with FileNotFoundError. Generate a real
PNG with PIL (the probe's own decoder) so the test is self-contained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thalida
thalida force-pushed the feat/issue-113-time-travel branch from 238fd6a to 6494cee Compare July 24, 2026 04:28
Trivy flagged 10 HIGH CVEs in Pillow 12.2.0 (DoS / heap OOB write across the
font, GD, JPEG2000, PDF and ImageCms parsers), all fixed in 12.3.0. Raise the
floor above the vulnerable range and relock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thalida
thalida merged commit 31d76e6 into main Jul 24, 2026
1 check passed
@thalida
thalida deleted the feat/issue-113-time-travel branch July 24, 2026 16:10
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.

Time-travel: scrub the repo's history and watch the city grow

1 participant