Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
53eebc1
feat(picker): add selectCommit for commit/tree landmark selection
thalida Jun 16, 2026
3cd351d
refactor(picker): tighten selectByCommit comment + list it in contract
thalida Jun 16, 2026
93c4559
feat(almanac): compute overview + building superlatives
thalida Jun 16, 2026
d7e002e
refactor(almanac): reuse shared formatBytes, narrow types, tighten na…
thalida Jun 16, 2026
c11edeb
feat(almanac): compute street, forest, and firefly facts
thalida Jun 16, 2026
ed78a9f
refactor(almanac): consistent pluralization + streets-omission test
thalida Jun 16, 2026
68b712d
feat(components): add PaneTabs in-pane subtab primitive
thalida Jun 16, 2026
ce81bb9
test(panetabs): use house render pattern, drop testing-library dep
thalida Jun 16, 2026
2ff2411
test(panetabs): cover ArrowLeft + wrap-around navigation
thalida Jun 16, 2026
8a24464
refactor(info): extract ReadmePane body from InfoPane
thalida Jun 16, 2026
4b54ad3
feat(info): add WorldPane almanac view with clickable landmarks
thalida Jun 16, 2026
28b28a4
refactor(worldpane): reuse fmtCount, index keys, cover commit + non-l…
thalida Jun 16, 2026
b52e274
feat(info): split Info tab into World | Readme subtabs
thalida Jun 16, 2026
b818eda
feat(worldpane): clean stacked almanac layout, dates, forest gating
thalida Jun 16, 2026
5dd3217
feat(worldpane): polish almanac per review — tags, focus icons, dedupe
thalida Jun 16, 2026
1c1f683
feat(almanac): split media into its own Billboards section
thalida Jun 19, 2026
619b609
style(worldpane): wrap each language badge + count in a bordered chip
thalida Jun 19, 2026
dc2708c
feat(info): rename World subtab to Overview; fix almanac row spacing
thalida Jun 19, 2026
844ef5e
feat(almanac): reorder building facts — date pairs first (newest befo…
thalida Jun 19, 2026
7e5fc7c
test(worldpane): regression tests for manifest reactivity
thalida Jun 19, 2026
841e9d6
feat(worldpane): add a hover tooltip to every almanac row
thalida Jun 19, 2026
0667f97
style: apply Prettier formatting to almanac + worldpane files
thalida Jun 19, 2026
539e3a6
refactor(info): address PR review
thalida Jun 20, 2026
f5f9bee
fix(city): supersede reactiveRebuild on dispose
thalida Jun 20, 2026
20729f3
feat(api): compute_repo_stats — per-repo superlatives + ranges
thalida Jun 20, 2026
0d1863a
refactor(api): simplify date-min picks + tighten stats helpers
thalida Jun 20, 2026
757e554
feat(api): RepoStats schema (TypedDict + Pydantic) + cache v8
thalida Jun 20, 2026
7063f40
refactor(api): FileLeader media-dims validator + TypedDict docstrings
thalida Jun 20, 2026
8925c31
feat(api): emit manifest.stats from scan_tree
thalida Jun 20, 2026
b219fb8
test(api): fix stats assertions + Manifest construction for required …
thalida Jun 20, 2026
b269e62
feat(types): regen manifest contract with stats; mirror in manifest.ts
thalida Jun 20, 2026
c8c5901
perf(layout): read file-size ranges from manifest.stats, drop the tre…
thalida Jun 20, 2026
00a70be
fix(layout): stats line/byte ranges are the non-zero rendering range …
thalida Jun 20, 2026
7e4fa76
refactor(almanac): map manifest.stats leaders to facts (drop client t…
thalida Jun 20, 2026
c313808
refactor(almanac): callback-secondary fileFact, simpler forest guard,…
thalida Jun 20, 2026
93cbe60
chore(layout): drop dead computeLineStats wrapper (test-only; compute…
thalida Jun 20, 2026
704daff
feat(stats): add commitDates (oldest/newest commit) to RepoStats
thalida Jun 20, 2026
ca22914
refactor(city): trees + fireflies read commit ranges/authors from stats
thalida Jun 20, 2026
6c2e911
style: prettier formatting for fireflies/trees test stats args
thalida Jun 20, 2026
d701543
test(bench): feed file-range stats to layoutCity in decoration benches
thalida Jun 20, 2026
36ded95
perf(stats): single pass per pool instead of a scan per superlative
thalida Jun 20, 2026
f145234
refactor(stats): name fields by metric, not render metaphor
thalida Jun 20, 2026
70680bd
refactor(almanac): object-arg fact builders, statFact, section tooltips
thalida Jun 20, 2026
534216c
refactor(info): render active.Component directly, drop the Body local
thalida Jun 20, 2026
6bdb908
perf(media): batch billboard image fetches via POST /api/files
thalida Jun 20, 2026
0ddaade
perf(fireflies): cap total orbs (MAX_FIREFLY_ORBS) like the tree cell…
thalida Jun 20, 2026
3c9e9d9
feat(overview): redesign the almanac — duos, section identity, row po…
thalida Jun 21, 2026
f5421e9
style(overview): drop the duo left-border accent
thalida Jun 21, 2026
18e5bfc
feat(overview): section summaries + a header on every fact group
thalida Jun 21, 2026
32fb86f
fix(overview): polish pass — alignment, visible affordance, NaN, balance
thalida Jun 21, 2026
92979ed
fix(overview): de-indent content, intro divider, single-billboard spo…
thalida Jun 21, 2026
9298ff7
fix(overview): flex-stretch rows (no width hack), +N more languages
thalida Jun 22, 2026
3b91975
fix(overview): fireflies lead with Most active, Least active below
thalida Jun 22, 2026
535f264
feat(overview): streets size = direct children, paired Smallest/Biggest
thalida Jun 22, 2026
a629cb0
fix(overview): give streets' Deepest a Depth header
thalida Jun 26, 2026
696f1da
style(overview): one color for the whole file path (dir no longer muted)
thalida Jun 26, 2026
4f4808d
fix(overview): lone billboard gets a Spotlight group + Only label
thalida Jun 26, 2026
27eed43
feat(almanac): polish the Overview intro card
thalida Jun 26, 2026
03077ba
feat(almanac): make Latest static when the Trees layer is off
thalida Jun 26, 2026
77bae58
feat(almanac): title each language-bar segment
thalida Jun 26, 2026
01d46c0
fix(almanac): stretch meta values to fill the right column
thalida Jun 26, 2026
e42e8ac
feat(almanac): give the blurb a middle-ground (add scale + fix article)
thalida Jun 26, 2026
90157fd
fix(almanac): make Latest informational with the sha as a real link
thalida Jun 26, 2026
8ebade1
feat(sidebar): Info tab leads the rail and opens on world load
thalida Jun 26, 2026
1adf3ed
refactor(sidebar): hoist the default tab into a UI constant
thalida Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 92 additions & 4 deletions api/models/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,107 @@ class BusynessThresholds(BaseModel):
# not optional. camelCase matches the frontend DateRanges + the fullPath
# precedent.
class DateRanges(BaseModel):
createdMin: Optional[str] = Field(
minCreated: Optional[str] = Field(
description="Earliest resolved create date (ISO), or null for an empty tree"
)
createdMax: Optional[str] = Field(
maxCreated: Optional[str] = Field(
description="Latest resolved create date (ISO), or null for an empty tree"
)
modifiedMin: Optional[str] = Field(
minModified: Optional[str] = Field(
description="Earliest resolved modify date (ISO), or null for an empty tree"
)
modifiedMax: Optional[str] = Field(
maxModified: Optional[str] = Field(
description="Latest resolved modify date (ISO), or null for an empty tree"
)


class RangeStat(BaseModel):
min: int
max: int


class FileLeader(BaseModel):
path: str
lines: int
bytes: int
created: str
modified: str
# Optional-but-non-nullable (absent for non-media leaders, a pixel count
# otherwise — never null); see OptionalInt above.
media_width: OptionalInt = None
media_height: OptionalInt = None

@model_validator(mode="after")
def _media_both_or_neither(self) -> "FileLeader":
if (self.media_width is None) != (self.media_height is None):
raise ValueError(
"media_width and media_height must both be set or both absent"
)
return self


class DirLeader(BaseModel):
path: str
depth: int
children: int
descendants: int


class CommitLeader(BaseModel):
sha: str
files: int


# Both fields required-nullable: the scanner always emits them (null for a repo
# with no commits), so they're present-but-nullable on the wire, not optional.
class CommitDateRange(BaseModel):
oldest: Optional[str] = Field(
description="Oldest commit date (YYYY-MM-DD), or null when the repo has no commits"
)
newest: Optional[str] = Field(
description="Newest commit date (YYYY-MM-DD), or null when the repo has no commits"
)


class DayLeader(BaseModel):
date: str
count: int


class AuthorStat(BaseModel):
name: str
commits: int


class RepoStats(BaseModel):
lineCountRange: RangeStat
byteSizeRange: RangeStat
oldestCreatedFile: Optional[FileLeader]
newestCreatedFile: Optional[FileLeader]
newestModifiedFile: Optional[FileLeader]
oldestModifiedFile: Optional[FileLeader]
maxLinesFile: Optional[FileLeader]
minLinesFile: Optional[FileLeader]
maxBytesFile: Optional[FileLeader]
minBytesFile: Optional[FileLeader]
maxMediaBytesFile: Optional[FileLeader]
minMediaBytesFile: Optional[FileLeader]
maxMediaPixelsFile: Optional[FileLeader]
minMediaPixelsFile: Optional[FileLeader]
mediaCount: int
totalLines: int
codeBytes: int
maxDepthDir: Optional[DirLeader]
maxChildrenDir: Optional[DirLeader]
minChildrenDir: Optional[DirLeader]
maxFilesPerCommit: Optional[CommitLeader]
minFilesPerCommit: Optional[CommitLeader]
commitDates: CommitDateRange
maxCommitsPerDay: Optional[DayLeader]
maxCommitStreakDays: int
authors: list[AuthorStat]


class Manifest(BaseModel):
root: str
scanned_at: str
Expand All @@ -138,6 +225,7 @@ class Manifest(BaseModel):
commits: list[CommitEntry]
busyness: BusynessThresholds
dateRanges: DateRanges
stats: RepoStats
# Optional-but-non-nullable (absent for local sources, a label string for
# git sources — never null); see OptionalStr above.
display_root: OptionalStr = None
Expand Down
8 changes: 8 additions & 0 deletions api/models/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ class FileTooLargeResponse(BaseModel):
limit: int


class FileBatchEntry(BaseModel):
"""One image in a POST /api/files batch response: its content-type and
base64-encoded bytes, keyed by request path in the response map."""

mime: str
b64: str


class HealthResponse(BaseModel):
ok: bool

Expand Down
50 changes: 48 additions & 2 deletions api/routers/file.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
"""GET /api/file — serve a file from disk, restricted to scanned roots."""
"""GET /api/file — serve a file from disk, restricted to scanned roots.

POST /api/files is the batch sibling used by the scene's media-texture loader:
one round trip for many small images instead of one request per billboard.
"""

from __future__ import annotations

import base64
import mimetypes
from pathlib import Path

from fastapi import APIRouter, HTTPException, Query, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from api.config import MAX_FILE_BYTES
from api.models.responses import FileTooLargeResponse
from api.models.responses import FileBatchEntry, FileTooLargeResponse
from api.security import NoRootsRegisteredError, OutsideRootError, TRUST
from api.services.media import is_media

router = APIRouter(prefix="/api", tags=["file"])

# Caps for POST /api/files. The client chunks its requests, but a server-side
# bound keeps any single response from ballooning: at most this many paths, and
# only images up to this size are base64-inlined — anything larger or non-image
# is omitted so the client falls back to the streaming GET /api/file path.
_MAX_BATCH_PATHS = 64
_MAX_BATCH_FILE_BYTES = 8 * 1024 * 1024


class FileBatchRequest(BaseModel):
paths: list[str]


@router.get("/file")
def get_file(
Expand Down Expand Up @@ -58,3 +75,32 @@ def get_file(
# Non-media (code, configs, extensionless) → text/plain so the preview
# renders the bytes as code; GZipMiddleware compresses it (text gzips well).
return Response(content=body, media_type="text/plain; charset=utf-8")


@router.post("/files")
def get_files(req: FileBatchRequest) -> dict[str, FileBatchEntry]:
"""Batch image fetch — return {path: {mime, b64}} for many small images in
one round trip. The scene loads one billboard texture per media file; firing
a separate GET per file exhausts the browser's HTTP/1.1 connection pool on
media-heavy repos, so the loader coalesces image paths into POST batches.

Each path is trust-checked exactly like GET /api/file. Paths that are out of
root, missing, non-image, or larger than _MAX_BATCH_FILE_BYTES are silently
omitted; the client falls back to the streaming GET for those. Videos are
never batched (they stream their poster frame), so this is images only.
"""
out: dict[str, FileBatchEntry] = {}
for path in req.paths[:_MAX_BATCH_PATHS]:
try:
target = TRUST.assert_inside(Path(path))
except (NoRootsRegisteredError, OutsideRootError, OSError, RuntimeError):
continue
if not target.is_file() or target.stat().st_size > _MAX_BATCH_FILE_BYTES:
continue
guessed, _ = mimetypes.guess_type(str(target))
if not guessed or not guessed.startswith("image/"):
continue
out[path] = FileBatchEntry(
mime=guessed, b64=base64.b64encode(target.read_bytes()).decode()
)
return out
4 changes: 3 additions & 1 deletion api/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ class FileEntry(TypedDict):
# treated as a miss and re-scanned. (Per-bump rationale lives in git history.)
_FILE_CACHE_VERSION = 1
_GIT_HISTORY_CACHE_VERSION = 12 # v12: dates UTC-normalized (file maps + commit days)
_MANIFEST_SCHEMA_VERSION = 7 # v7: FileNode.mediaKind added
_MANIFEST_SCHEMA_VERSION = (
11 # v11: dir leaders by direct children (max/minChildrenDir) + totals
)
# Composite: invalidates when EITHER the manifest schema OR the git-history
# shape changes. Stored as a string in the cache file's `version` field.
_MANIFEST_CACHE_VERSION: str = (
Expand Down
108 changes: 104 additions & 4 deletions api/services/manifest_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,109 @@ class DateRanges(TypedDict):
gradients read one consistent range instead of re-walking the tree.
All four are None for a tree with zero files."""

createdMin: str | None
createdMax: str | None
modifiedMin: str | None
modifiedMax: str | None
minCreated: str | None
maxCreated: str | None
minModified: str | None
maxModified: str | None


class RangeStat(TypedDict):
"""Inclusive min/max of an integer metric (lines, bytes)."""

min: int
max: int


class FileLeader(TypedDict):
"""The file that wins a per-repo superlative, with the values the Overview renders (path + lines/bytes/dates, optional media dims)."""

path: str
lines: int
bytes: int
created: str
modified: str
media_width: NotRequired[int]
media_height: NotRequired[int]


class DirLeader(TypedDict):
"""The directory that wins a street superlative. `children` = direct
children (files + sub-dirs on that street); `descendants` = everything below
it; `depth` = nesting level."""

path: str
depth: int
children: int
descendants: int


class CommitLeader(TypedDict):
"""The commit that wins a superlative (most / fewest files changed)."""

sha: str
files: int


class CommitDateRange(TypedDict):
"""Oldest/newest commit date (YYYY-MM-DD) across the lookback window — the
scene's tree-age normalization range, computed once so the trees + firefly
orbits read it instead of re-scanning commits. Both None for a repo with
zero commits."""

oldest: str | None
newest: str | None


class DayLeader(TypedDict):
"""A calendar date with a commit count (the busiest day)."""

date: str
count: int


class AuthorStat(TypedDict):
"""One author and their total commit count."""

name: str
commits: int


class RepoStats(TypedDict):
Comment thread
thalida marked this conversation as resolved.
"""Per-repo derived stats computed once at manifest-wrap
(api/services/stats.py): the Overview almanac superlatives + min/max
ranges. The web app reads these instead of re-walking the tree."""

# Building-size NORMALIZATION ranges (non-zero), NOT honest min/max file
# size. Over all files with non-zero lines/size — 0-length files are
# excluded because they have no meaningful building height/width (and the
# frontend log/sqrt can't take 0). The honest "smallest file" lives in the
# minBytesFile / minLinesFile leaders. {0,0} when no non-zero files exist.
lineCountRange: RangeStat
byteSizeRange: RangeStat
oldestCreatedFile: FileLeader | None
newestCreatedFile: FileLeader | None
newestModifiedFile: FileLeader | None
oldestModifiedFile: FileLeader | None
maxLinesFile: FileLeader | None
minLinesFile: FileLeader | None
maxBytesFile: FileLeader | None
minBytesFile: FileLeader | None
maxMediaBytesFile: FileLeader | None
minMediaBytesFile: FileLeader | None
maxMediaPixelsFile: FileLeader | None
minMediaPixelsFile: FileLeader | None
mediaCount: int
totalLines: int
codeBytes: int
maxDepthDir: DirLeader | None
maxChildrenDir: DirLeader | None
minChildrenDir: DirLeader | None
maxFilesPerCommit: CommitLeader | None
minFilesPerCommit: CommitLeader | None
commitDates: CommitDateRange
maxCommitsPerDay: DayLeader | None
maxCommitStreakDays: int
authors: list[AuthorStat]


class Manifest(TypedDict):
Expand All @@ -178,6 +277,7 @@ class Manifest(TypedDict):
commits: list[CommitEntry]
busyness: BusynessThresholds
dateRanges: DateRanges
stats: RepoStats
display_root: NotRequired[str]


Expand Down
10 changes: 6 additions & 4 deletions api/services/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
cache_save_git_history,
)
from .media import media_kind, probe_media_dims
from .stats import compute_repo_stats
from .manifest_types import (
BusynessThresholds,
CommitEntry,
Expand Down Expand Up @@ -1188,10 +1189,10 @@ def _compute_date_ranges(tree: DirNode) -> DateRanges:
mmin = m if mmin is None or m < mmin else mmin
mmax = m if mmax is None or m > mmax else mmax
return {
"createdMin": cmin,
"createdMax": cmax,
"modifiedMin": mmin,
"modifiedMax": mmax,
"minCreated": cmin,
"maxCreated": cmax,
"minModified": mmin,
"maxModified": mmax,
}


Expand Down Expand Up @@ -1220,6 +1221,7 @@ def _wrap_manifest(
"commits": commits,
"busyness": _compute_busyness(commits),
"dateRanges": _compute_date_ranges(tree),
"stats": compute_repo_stats(tree, commits),
}


Expand Down
Loading