Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
201 commits
Select commit Hold shift + click to select a range
70cc4b6
Phase 2: Adopt Preact + signals (toolchain setup)
thalida May 30, 2026
67bbd08
Phase 2 followup: move flex column layout from body to #app
thalida May 30, 2026
6a96e21
Phase 3a: convert nanostores → @preact/signals across state surface
thalida May 30, 2026
e4557d3
Phase 3b: port icon.ts to Preact (LucideIcon + GemIcon components)
thalida May 30, 2026
02112af
Phase 3b: port badge.ts to Preact (ExtensionBadge component)
thalida May 30, 2026
4c6f4a9
Phase 3b: port pathTruncate.ts to .tsx
thalida May 30, 2026
9948c67
Phase 3b: port fileIcon.ts to Preact (FileIcon + FolderIcon components)
thalida May 30, 2026
c72e545
Phase 3b: port paneHeader.ts to Preact (PaneHeader component)
thalida May 30, 2026
d9e96cc
Phase 3b: port tooltip.ts to Preact + fix TOOLTIP.get() → .value
thalida May 30, 2026
bcfd384
Phase 3b: port loadingOverlay.ts to Preact (LoadingOverlay component)
thalida May 30, 2026
ac6d60b
Phase 3b: port sourcePicker.ts to Preact (SourcePickerComponent)
thalida May 30, 2026
d4eb468
Phase 3c: port infoPane to Preact
thalida May 30, 2026
02fd05e
Phase 3c: port searchPane to Preact
thalida May 30, 2026
43a2ffa
Phase 3c: port streetPane to Preact
thalida May 30, 2026
d859fcb
Phase 3c: port filePreviewPane to Preact
thalida May 30, 2026
8ccd45a
Phase 3c: port treePane to Preact
thalida May 30, 2026
12f1e54
Phase 3c: port commitPane to Preact
thalida May 30, 2026
8a7a0e4
Phase 3c: port controlsPane to Preact
thalida May 30, 2026
1741436
Phase 3c followup: unblock app boot — patch 2 stray .get() calls in a…
thalida May 30, 2026
924371c
Phase 3c followup: fix coordinator over-tracking after subscribe→effe…
thalida May 30, 2026
4f235d4
Phase 3d: port appFooter to Preact
thalida May 30, 2026
824a88c
Phase 3d: port appHeader to Preact
thalida May 30, 2026
0a2b28a
Phase 3d: port leftSidebar to Preact
thalida May 30, 2026
0d3435e
Phase 3d: port rightSidebar to Preact
thalida May 30, 2026
ea5f457
feat(3e): CenterPane + scene/sourceInfo/uiState runtime signals
thalida May 30, 2026
a5101e5
refactor(3e): AppHeader reads SCENE_HANDLE + SOURCE_INFO signals dire…
thalida May 30, 2026
19e2d36
refactor(3e): AppFooter reads status/picker signals directly
thalida May 30, 2026
b083175
refactor(3e): LeftSidebar + RightSidebar self-subscribe to SCENE_HANDLE
thalida May 30, 2026
735c87e
feat(3e): real App.tsx composition root + appLogic.ts bootstrap module
thalida May 30, 2026
0b25007
feat(3e): delete coordinator.ts
thalida May 30, 2026
20d786b
feat(3e): delete boot.ts; extract applyPendingTitle + EMPTY_MANIFEST …
thalida May 30, 2026
43ba2ad
fix(3e): clarify SCENE_HANDLE.value tracking comment in AppFooter
thalida May 30, 2026
058c8a0
Phase 3 audit: fix two over-tracking effect() blocks
thalida May 30, 2026
acb1b03
3.5.1 #1: source picker defaults to git tab, not local
thalida May 31, 2026
89be97c
3.5.1 #2: source picker form inputs reset on close
thalida May 31, 2026
37eeff7
3.5.1 #3: header switch-source button on URL-prefilled boot
thalida May 31, 2026
59b1b69
3.5.1 #4: shell components own their outer tags + rename sidebar IDs
thalida May 31, 2026
af8dcb6
3.5.1 #5: copy-path button copies project-relative path
thalida May 31, 2026
c77f6fa
3.5.1 #6: re-click same building reopens right sidebar after manual c…
thalida May 31, 2026
7586427
3.5.1 #23: derive commit-busyness thresholds from per-repo data
thalida May 31, 2026
9e37fb9
3.5.3.1 #8: remove unused imports across views/ + 1 test
thalida May 31, 2026
c17702a
3.5.2.1 #41: rewrite persist.ts signals-native (~200 lines, was 349)
thalida May 31, 2026
f75d877
3.5.2.1 #41: convert all settings files to persistedSignal()
thalida May 31, 2026
09d7f41
3.5.2.1 #41: PICKER_SELECTION_KEY uses perSourceSignal
thalida May 31, 2026
dfe854b
3.5.2.1 #41: explicit save/load per-source on source switch
thalida May 31, 2026
274272a
3.5.2.1 #41: controlsPane bridges onAnyChange to HAS_ANY_NON_DEFAULT
thalida May 31, 2026
8294c6b
3.5.2.1 #41: update tests to new perSourceSignal + import-triggers-re…
thalida May 31, 2026
8389626
3.5.2.1 #41 (extended): convert sourceRecents + sidebar collapsed fla…
thalida May 31, 2026
6d8310a
3.5.2.2 #42: rebuild reactions.ts signals-native
thalida May 31, 2026
b19818f
3.5.2.3 #37: extract manifest streaming + source-picker bridge into r…
thalida May 31, 2026
145f6af
3.5.2.3 #31: LoadingOverlay + SourcePicker read runtime signals directly
thalida May 31, 2026
3f78b04
3.5.2.3 #37: extract boot orchestration into runtime/boot.ts + sideba…
thalida May 31, 2026
8fe2b68
3.5.2.3 #37: restore SCENE_HANDLE=null teardown in boot.ts dispose()
thalida May 31, 2026
9ebd3d9
3.5.2.3 #37 (F2/F4): resolve CenterPane double-mount risk
thalida May 31, 2026
8a6c4a0
3.5.2.4 #47: signals-native hljs theme via <HljsThemeLink /> component
thalida May 31, 2026
fc23d42
3.5.3.1: migration leftovers — _legacy rename + .subscribe sweep
thalida May 31, 2026
69233ca
3.5.3.2 #20: route all API calls through app/src/api/
thalida May 31, 2026
81ccfb7
3.5.3.3: icon discipline — Lucide everywhere, extract hosting icons, …
thalida May 31, 2026
c914f72
3.5.3.4 #18: SourceTab enum replaces 'local' | 'git' string union
thalida May 31, 2026
7ad95d5
3.5.3.5 #16 + #46: relocations — pathTruncate → middleEllipsis; EMPTY…
thalida May 31, 2026
ebb5dee
3.5.3.5 #44: merge liveStatus + liveUpdates → manifestPoll
thalida May 31, 2026
3456bfc
3.5.3.5 #45: merge sourceContext + sourceInfo → activeSource
thalida May 31, 2026
cc0e351
3.5.3.6 #9: extract generic helpers from views to utils/
thalida May 31, 2026
39622d4
3.5.3.6 #12 + #13: split data tables to constants/, resolvers to utils/
thalida May 31, 2026
f2a3657
3.5.3.6 #15: co-locate PreviewKind enum with its only consumer
thalida May 31, 2026
3397e26
3.5.4.1 #14 + #7: one component per file + PascalCase view filenames
thalida May 31, 2026
78ccc84
3.5.4.2 #27: drop "No remote configured" empty-state
thalida May 31, 2026
e1de8ba
3.5.4.3 (F1 partial): sidebars own their <aside> element
thalida May 31, 2026
d0659cb
3.5.4.4 #29: memoize O(n) manifest derivations
thalida May 31, 2026
d87956c
3.5.4.5 #32: scene-side dispatchers replace view reach-throughs
thalida May 31, 2026
d95198b
3.5.4.6 #10 (partial): delete dead-only shims
thalida May 31, 2026
4e63b35
3.5.4.3 #34: break up AppHeader into sub-components
thalida May 31, 2026
cbcb891
3.5.4.3 #28: full Preact rewrite of ControlsPane
thalida May 31, 2026
8d07766
3.5.4.3 #30 + a11y: real Preact TreePane + reset btns out of <summary>
thalida May 31, 2026
9499a10
3.5.4.3 #35: full Preact port of LeftSidebar
thalida May 31, 2026
031bd46
3.5.4.3 #36: full Preact port of RightSidebar
thalida May 31, 2026
b97a298
3.5.4.6 #10: delete LeftSidebarShell/RightSidebarShell aliases + moun…
thalida May 31, 2026
5991f5d
3.5 gap-closure: generic <Pane> (#26/#25), shim deletion (#10), scene…
thalida May 31, 2026
514f25e
3.5 gap-closure: address xhigh code-review findings
thalida May 31, 2026
908bfd2
3.5 gap-closure: FilePreviewPane full-JSX body (#14) + shared test he…
thalida May 31, 2026
909fad5
3.5 review2 [H]: delete state dead-code (drafts.subscribe/_listeners,…
thalida May 31, 2026
f7690bb
3.5 review2 [J]: small dedups — html.ts, isEmptyManifest, NodeIcon, H…
thalida May 31, 2026
8d7ec78
3.5 review2 [G]: centralize constants + fix recents key
thalida May 31, 2026
a29484b
3.5 review2 [B]: delete all 5 backward-compat factories
thalida May 31, 2026
860661b
3.5 review2 [C]: purge imperative DOM from views + add src/hooks
thalida May 31, 2026
616dd57
3.5 review2 [E.1]: bake per-dir extension breakdown into the manifest…
thalida May 31, 2026
e0dffc3
3.5 review2: stop persisting/reapplying the picker selection
thalida May 31, 2026
c0bbd0f
3.5 review2: drop the (git) date-source label from the footer
thalida May 31, 2026
5b812ea
3.5 review2: render README images in the Info panel
thalida May 31, 2026
ebfb113
3.5 review2: fix broken folder/file icon names (scene, state, require…
thalida May 31, 2026
f3536b1
3.5 review2: vendor Lucide icons + add an icon-name validation guard
thalida Jun 1, 2026
d92adac
3.5 review2 [E.2/#35]: unify busyness on backend-computed thresholds
thalida Jun 1, 2026
4af326d
3.5 review2: backend halves of E.1 + E.2 (were stranded in the main c…
thalida Jun 1, 2026
48e8532
3.5 review2: fix git-history cache dropping authors + always write ca…
thalida Jun 1, 2026
2b590f5
3.5 review2: use lucide-preact icon components directly (drop wrapper…
thalida Jun 1, 2026
631762a
3.5 review2: self-host Material file icons (bundle from npm, drop CDN)
thalida Jun 1, 2026
10a2433
3.5 review2 [F.1]: hoist the manifest signal to a canonical state/ mo…
thalida Jun 1, 2026
4204443
3.5 review2 [F.2+F.3]: colocate REBUILD_STATUS→overlay bridge; drop s…
thalida Jun 1, 2026
612b379
3.5 review2 [F.4 / #15]: document.title + last-updated derived from M…
thalida Jun 1, 2026
444e22a
3.5 review2 [F.5]: move manifestStream + sourcePickerBridge into stat…
thalida Jun 1, 2026
7e5535c
3.5 review2 [F.6+F.7 / #39]: CenterPane owns the canvas+scene; delete…
thalida Jun 1, 2026
772ece3
3.5 review2 [#25-rest]: LoadingStep → string enum + hoisted step cons…
thalida Jun 1, 2026
c1e1d6c
3.5 review2 [E / #35]: bake per-commit same_day_total; delete client …
thalida Jun 1, 2026
30b1841
3.5 review2 [D, prep]: delete the dead CAMERA_ANIMATION settings store
thalida Jun 1, 2026
501db9c
3.5 review2 [D1+D2]: schema-driven settings infra + prove on TREES
thalida Jun 1, 2026
39e8103
3.5 review2 [D]: make controls/sections a folder; rename to DynamicSe…
thalida Jun 1, 2026
142ccf3
3.5 review2 [D]: ControlsPane renders sections from an ordered list v…
thalida Jun 1, 2026
ee1c565
3.5 review2 [D fan-out]: flat settings layout, key-naming convention,…
thalida Jun 1, 2026
4cc6a96
3.5 review2 [D fan-out]: Island → one flat ISLAND store
thalida Jun 1, 2026
d57ea7e
3.5 review2 [D fan-out]: Scene → flat SCENE store (+ WORLD kept separ…
thalida Jun 1, 2026
e643b60
3.5 review2 [D fan-out]: Gem → flat GEM store (+ GEM_SIZING, REPO_LAB…
thalida Jun 1, 2026
d1a89ca
3.5 review2 [D]: evict non-tunable system stores (camera/input/toolti…
thalida Jun 1, 2026
2f44b02
3.5 review2 [D]: relocate syntaxTheme flat; remove settings/prefs/
thalida Jun 1, 2026
231536a
3.5 review2 [D]: auto-generate reactions signatures from per-field ro…
thalida Jun 1, 2026
453f29e
3.5.2.2 #42: rename MATERIAL_REFRESH_SIGNATURE → REFRESH_SIGNATURE
thalida Jun 1, 2026
6e2dc4e
3.5.2.2 #42: delete persistStore escape hatch; tests use persistedSignal
thalida Jun 1, 2026
9526372
3.5.2.2 #42: dedup deepEqual/deepClone + unify persist registry
thalida Jun 1, 2026
9d7c769
3.5.2.2 #42: move deepEqual/deepClone to utils/deep
thalida Jun 1, 2026
5076d9c
3.5.2.2 #42: merge street visual stores into schema-driven STREETS
thalida Jun 1, 2026
f3a13b1
3.5.2.2 #42: streets structural conversion + footprint section + flatten
thalida Jun 1, 2026
7824911
3.5.2.2 #42: convert BUILDING_DIMENSIONS to schema-driven settingSignal
thalida Jun 1, 2026
9f7549e
3.5.2.2 #42: merge building visuals into schema-driven BUILDINGS (B2)
thalida Jun 1, 2026
78ede75
3.5.2.2 #42: merge facade stores + ad panels into schema-driven FACAD…
thalida Jun 1, 2026
c39fd9e
3.5.2.2 #42: convert BUILDING_FADE to schema-driven settingSignal (B4)
thalida Jun 1, 2026
c7ca381
3.5.2.2 #42: Buildings section → declaration + flatten (B5)
thalida Jun 1, 2026
32d0f7c
3.5.2.2 #42: evict LIGHTING to constants; settings folder fully flat
thalida Jun 1, 2026
73d3302
3.5.2.2 #42: move schema.ts to state/ root (settings infra, not a set…
thalida Jun 1, 2026
a0e9b2d
3.5.2.2 #42: move settings/ under state/stores/ (step 1 of runtime re…
thalida Jun 1, 2026
f7b1f39
3.5.2.2 #42: extract domain stores to state/stores/ (step 2)
thalida Jun 1, 2026
65a6255
3.5.2.2 #42: dissolve state/runtime/ — manifest store + useCity hook …
thalida Jun 1, 2026
413322f
3.5.2.2 #42: draft-driven controls reset (D4) — kill the DOM scrape
thalida Jun 1, 2026
aa220c2
3.5.2.2 #42: [A] move views/components/ → src/components/ (A1)
thalida Jun 1, 2026
538a54c
3.5.2.2 #42: [A] flatten views/panes/ → views/ (A2+A3)
thalida Jun 1, 2026
cdc031b
3.5.2.2 #42: [A] SourcePicker → views/, inner renamed to SourcePicker…
thalida Jun 1, 2026
cab3023
3.5.2.2 #42: [A5a] controls primitives → components/, hooks → src/hooks/
thalida Jun 1, 2026
3baceb5
3.5.2.2 #42: [A5b] ControlsPane → folder + PascalCase section partials
thalida Jun 1, 2026
6561478
3.5.2.2 #42: [A6] decompose AppHeader into focused components
thalida Jun 1, 2026
8f8ce1e
3.5.2.2 #42: [A] mirror test dirs to the new source layout
thalida Jun 1, 2026
f3b4182
3.5.2.2 #42: settings control labels ellipsize when truncated
thalida Jun 1, 2026
1f8ad0a
3.5.2.2 #42: settings Reset-all no longer wipes non-settings persiste…
thalida Jun 1, 2026
ed3e3ec
3.5.2.3 #43: make state/ view-independent — move UI-state contracts i…
thalida Jun 1, 2026
c4fb03f
3.5.2.4 #44: rename schema/drafts/reactions → settings* (they're sett…
thalida Jun 1, 2026
e6f707a
3.5.2.5 #45: fix stale file-path header comments left by the restruct…
thalida Jun 1, 2026
93076dc
3.5.2.6 #46: make hardcoded Badge/CopyButton constants overridable vi…
thalida Jun 1, 2026
55d0c95
3.5.2.7 #47: extract TierWidthsField + HueMapField from Field.tsx int…
thalida Jun 1, 2026
25fe93f
3.5.2.8 #48: inline-SVG icon cleanup — GemIcon + HostingIcon globe
thalida Jun 1, 2026
f5fdff1
3.5.2.9 #49: replace 'git'|'local' and step-state string unions with …
thalida Jun 1, 2026
b784429
3.5.2.10 #50: ResetButton scales to any number of keys (was brittle a…
thalida Jun 1, 2026
893bc9d
3.5.2.11 #51: sweep stale comment references to renamed/moved modules
thalida Jun 1, 2026
3428a91
3.5.2.12 #52: drop the one-export hooks barrel; import useMiddleEllip…
thalida Jun 1, 2026
f7996a7
3.5.2.13 #53: rename hooks/useControls → hooks/useSettings
thalida Jun 1, 2026
acf3508
3.5.2.14 #54: useMiddleEllipsis observes a caller-nameable element, n…
thalida Jun 1, 2026
7a370c7
3.5.2.15 #55: consolidate useField into useSettings (one settings-hoo…
thalida Jun 1, 2026
d54cb7d
3.5.2.16 #56: type the scan-stream phase via a ScanPhase enum
thalida Jun 1, 2026
d829a05
3.5.2.17 #57: restructure useCity — extract the shared stream protoco…
thalida Jun 1, 2026
4e50360
3.5.2.18 #58: move header slot widgets from layout/header/ to compone…
thalida Jun 1, 2026
083f692
3.5.2.19 #59: rename HeaderBreadcrumb → PathBreadcrumbs
thalida Jun 1, 2026
cdb4345
3.5.2.20 #60: move RepoLink's forge branch-URL builder into utils/sou…
thalida Jun 1, 2026
c793541
3.5.2.21 #61: merge CollapsibleSubgroup into Subgroup behind a `colla…
thalida Jun 1, 2026
a4278d0
3.5.2.22 #62: ResetViewButton derives its (R) hint from KEY_BINDINGS
thalida Jun 1, 2026
e657808
3.5.2.23 #63: derive all (F) focus-shortcut hints from KEY_BINDINGS
thalida Jun 1, 2026
60f5703
3.5.2.24 #64: drop vestigial _clearLegacyControlsState from LeftSidebar
thalida Jun 1, 2026
53cfebc
3.5.2.25 #65: RebuildStatus → enum; tidy AppFooter status strings
thalida Jun 1, 2026
1f2c137
3.5.2.26 #66: persist sidebar widths via persistedSignal; rename keys…
thalida Jun 2, 2026
033c406
3.5.2.27 #67: activity-bar tab placement → TabPlacement enum
thalida Jun 2, 2026
0ce3574
3.5.2.28 #68: LeftSidebar width bounds become optional props with def…
thalida Jun 2, 2026
2123e3b
3.5.2.29 #69: extract a shared <Sidebar> wrapper; let CSS own the wid…
thalida Jun 2, 2026
f5c68b4
3.5.2.30 #70: RightSidebar pane kind → SidebarPaneKind enum
thalida Jun 2, 2026
a5feb9f
3.5.2.31 #71: add SceneHandle.focusByPath so focus handlers are clean…
thalida Jun 2, 2026
2ea2d4b
3.5.2.32 #72: simplify RightSidebar's scene bridge to one component-l…
thalida Jun 2, 2026
5b9815f
3.5.2.33 #73: centralize scene commands in stores/scene; RightSidebar…
thalida Jun 2, 2026
10371cd
3.5.2.34 #74: drop main.tsx test-only re-export shim; import from rea…
thalida Jun 2, 2026
5c3af0a
3.5.2.35 #75: fix README images authored as raw <img> tags not loading
thalida Jun 2, 2026
5d2a08d
3.5.2.36 #76: remove DOM_IDS — components set their own id literals
thalida Jun 2, 2026
0842b18
3.5.2.37 #77: remove the constants barrel; import each module directly
thalida Jun 2, 2026
6ff97f1
3.5.2.38 #78: drop vestigial git_window URL-param cleanup in syncUrlT…
thalida Jun 2, 2026
6c05f5e
3.5.2.39 #79: remove the settings barrel; import each settings store …
thalida Jun 2, 2026
08d634b
3.5.2.40 #80: centralize URL query-param keys in URL_PARAMS
thalida Jun 2, 2026
5fe9206
3.5.2.41 #81: move display-label + icon-atlas setup into world.applyM…
thalida Jun 2, 2026
7373718
3.5.3.1 #82: MANIFEST is a written source-of-truth signal (drop world…
thalida Jun 2, 2026
4821c80
3.5.3.2 #83: add useCityScene render hook; CenterPane applies MANIFES…
thalida Jun 2, 2026
6f605b9
3.5.3.3 #84: add useManifestSource fetch hook (writes MANIFEST); App …
thalida Jun 2, 2026
0e87f5a
3.5.3.4 #29: remove useCity (split into useManifestSource + useCitySc…
thalida Jun 2, 2026
0a5adc6
3.5.3.5 #30: SourcePicker via props; drop registerSourceApplier/submi…
thalida Jun 2, 2026
65aebed
3.5.3.7 #32: settingsReactions applies MANIFEST (source of truth); do…
thalida Jun 2, 2026
54227ea
3.5.3.8: fix REBUILD_STATUS lifecycle holes found in final review
thalida Jun 2, 2026
c591e42
3.6.1 #refresh: delete dead manual-refresh chokepoint
thalida Jun 2, 2026
a674370
3.6.2 #title: useDocumentTitle owns all document.title writes
thalida Jun 2, 2026
22a22e5
3.6.2.1 #title: consolidate pending label into one canonical signal
thalida Jun 2, 2026
92be9a3
3.6.3 #liveupdates: poll loop reads MANIFEST.signature; drop handle p…
thalida Jun 2, 2026
e0c0ce7
3.6.4 #current-source: derive CURRENT_SOURCE_KEY + URL from CURRENT_S…
thalida Jun 2, 2026
40334a1
3.6.5 #source-info: derive SOURCE_INFO from MANIFEST + CURRENT_SOURCE
thalida Jun 2, 2026
f099872
3.6.6 #scan-progress: loading overlay becomes a reaction to SCAN_PROG…
thalida Jun 2, 2026
7490a41
3.6.7 #picker: App coordinates the source picker; useManifestSource i…
thalida Jun 2, 2026
97aabd7
3.6.8 #rebuild-status: always flip Rebuilding on apply; drop the cold…
thalida Jun 2, 2026
38d5787
3.7.1 #camera: explicit reset-on-source-change in useCityScene; camer…
thalida Jun 2, 2026
b32efe0
3.7.2 #loadsource: one canonical loadSource (boot+switch); setCurrent…
thalida Jun 2, 2026
81aa5ce
3.7.3 #sweep: doc/comment tidy for fetch-view decouple + final gate
thalida Jun 2, 2026
89f8887
3.7.4 #camera-fix: reset camera on the FIRST loaded source too (no-?s…
thalida Jun 2, 2026
0227602
3.7.5 #camera-fix2: reframe on the FINAL apply, not the skeleton
thalida Jun 2, 2026
b4a4757
chore: prettier --write (format drift from the unpushed branch)
thalida Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
34 changes: 31 additions & 3 deletions api/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,20 @@
# ALWAYS_SKIP. Cached manifests that observed lockfiles (or that came
# from include_all=true scans) would no longer match a fresh scan;
# bumping forces every repo to re-cache and drops the orphans.
_MANIFEST_SCHEMA_VERSION = 2
# v3: DirNode gained descendants_ext_breakdown and the manifest gained
# top-level busyness thresholds. Pre-v3 cached manifests lack both, so
# consumers (street view, commit-pane busyness label, scene tree color)
# would mis-render until a fresh scan; bumping forces the re-cache.
# v4: a latent bug in cache_load_git_history dropped each commit's authors
# + subject when reconstructing from the git-history cache. It never bit
# until the v3 bump invalidated the manifest blobs and forced a re-scan that
# rebuilds commits FROM that cache — which then cached author-less manifests
# (fireflies/header crash on commit.authors). The loader is fixed; this bump
# discards the polluted v3 blobs so a normal load re-scans correctly.
# v5: each CommitEntry gained same_day_total (commits sharing its date),
# baked at wrap time so the commit pane + scene tree-color read one field
# instead of recomputing the per-day grouping. Pre-v5 blobs lack it.
_MANIFEST_SCHEMA_VERSION = 5
# Composite cache version: invalidates when EITHER the manifest
# schema OR the git-history cache shape changes. Stored as a string
# in the cache file's `version` field; the loader's equality check
Expand Down Expand Up @@ -235,11 +248,26 @@ def cache_load_git_history(
date = c.get("date")
files = c.get("files")
sha = c.get("sha")
authors = c.get("authors")
subject = c.get("subject")
# Reconstruct the FULL CommitEntry — authors + subject are part of the
# shape (v9/v11) and manifest consumers (fireflies iterate authors,
# the commit pane shows subject) break without them. Drop any commit
# missing/malformed on any field rather than emit a partial entry.
if (isinstance(date, str)
and isinstance(files, int) and not isinstance(files, bool)
and isinstance(sha, str)
and _SHA_HEX_RE.fullmatch(sha) is not None):
commits.append({"date": date, "files": files, "sha": sha})
and _SHA_HEX_RE.fullmatch(sha) is not None
and isinstance(authors, list)
and all(isinstance(a, str) for a in authors)
and isinstance(subject, str)):
commits.append({
"date": date,
"files": files,
"sha": sha,
"authors": authors,
"subject": subject,
})
return created, modified, commits


Expand Down
110 changes: 89 additions & 21 deletions api/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@
)
from .media import probe_media_dims
from .types import (
BusynessThresholds,
CommitEntry,
DirNode,
ExtBreakdownEntry,
FileEntry,
FileNode,
GitMeta,
Expand Down Expand Up @@ -409,7 +411,10 @@ def _collect_git_metadata(
tracked = _collect_tracked_set(root)
_log(f" {len(tracked)} tracked entries (files + dirs)")

if use_cache and head_sha:
# Always write the cache (only `head_sha` is required to key it) —
# `use_cache` gates the READ above, not the write. A skip-cache scan
# still refreshes the cache so the next normal run is up to date.
if head_sha:
try:
cache_save_git_history(root, head_sha, created, modified, commits)
except OSError:
Expand Down Expand Up @@ -804,26 +809,27 @@ def _populate_file_metadata(
raise
_log(f" read {len(miss_paths)}/{len(miss_paths)} files")

if use_cache:
# Union-merge: start from the loaded cache (preserves entries
# for files not visited this scan, e.g. when .codecityignore flips)
# and overwrite with current values for everything we did visit.
for node in nodes:
entry: FileEntry = {
"size": node["size"],
"mtime": _node_mtime(node),
"lines": node["lines"],
"binary": node["binary"],
"ext": node["extension"],
}
if "media_width" in node and "media_height" in node:
entry["media_width"] = node["media_width"]
entry["media_height"] = node["media_height"]
cache_entries[node["path"]] = entry
try:
cache_save_files(abs_root, cache_entries)
except OSError:
pass
# Always write the file-stat cache — `use_cache` gates only the READ.
# On a warm scan cache_entries holds the loaded cache, so this is a
# union-merge (preserves entries for files not visited this scan, e.g.
# when .codecityignore flips); on a skip-cache scan it starts empty and
# records exactly the files this fresh scan visited.
for node in nodes:
entry: FileEntry = {
"size": node["size"],
"mtime": _node_mtime(node),
"lines": node["lines"],
"binary": node["binary"],
"ext": node["extension"],
}
if "media_width" in node and "media_height" in node:
entry["media_width"] = node["media_width"]
entry["media_height"] = node["media_height"]
cache_entries[node["path"]] = entry
try:
cache_save_files(abs_root, cache_entries)
except OSError:
pass


def _iter_file_nodes(tree: DirNode) -> Iterator[FileNode]:
Expand Down Expand Up @@ -901,6 +907,7 @@ class _DirFrame:
"pending_entries", "files", "subdirs",
"descendants_count", "descendants_file_count",
"descendants_dir_count", "descendants_size",
"ext_breakdown",
)

def __init__(self, abs_dir: str, rel_dir: str) -> None:
Expand All @@ -921,6 +928,9 @@ def __init__(self, abs_dir: str, rel_dir: str) -> None:
self.descendants_file_count = 0
self.descendants_dir_count = 0
self.descendants_size = 0
# ext (lowercase, "(none)" if absent) -> [count, total_size], over
# all descendant files. Merged up from child frames as they pop.
self.ext_breakdown: dict[str, list[int]] = {}


def _build_tree(
Expand Down Expand Up @@ -971,6 +981,13 @@ def _build_tree(
top.descendants_count += 1
top.descendants_file_count += 1
top.descendants_size += node["size"]
ext = (node["extension"] or "(none)").lower()
bucket = top.ext_breakdown.get(ext)
if bucket is None:
top.ext_breakdown[ext] = [1, node["size"]]
else:
bucket[0] += 1
bucket[1] += node["size"]
heartbeat.tick()
elif entry.is_dir(follow_symlinks=False):
# Descend by pushing a new frame; rollup happens when it
Expand All @@ -982,6 +999,13 @@ def _build_tree(
# (root) or attach to the parent.
finished = stack.pop()
children: list[FileNode | DirNode] = [*finished.files, *finished.subdirs]
# Sort by count desc, then ext asc for deterministic output.
ext_breakdown_out: list[ExtBreakdownEntry] = [
{"ext": ext, "count": cnt, "size": size}
for ext, (cnt, size) in sorted(
finished.ext_breakdown.items(), key=lambda kv: (-kv[1][0], kv[0])
)
]
node_out: DirNode = {
"name": finished.name,
"type": NodeKind.DIRECTORY,
Expand All @@ -994,6 +1018,7 @@ def _build_tree(
"descendants_file_count": finished.descendants_file_count,
"descendants_dir_count": finished.descendants_dir_count,
"descendants_size": finished.descendants_size,
"descendants_ext_breakdown": ext_breakdown_out,
"children": children,
}
if not stack:
Expand All @@ -1004,6 +1029,14 @@ def _build_tree(
parent.descendants_file_count += node_out["descendants_file_count"]
parent.descendants_dir_count += 1 + node_out["descendants_dir_count"]
parent.descendants_size += node_out["descendants_size"]
# Merge the child's per-extension breakdown up into the parent.
for ext, (cnt, size) in finished.ext_breakdown.items():
bucket = parent.ext_breakdown.get(ext)
if bucket is None:
parent.ext_breakdown[ext] = [cnt, size]
else:
bucket[0] += cnt
bucket[1] += size


# ── Public entry ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1038,6 +1071,27 @@ def _walk(node: dict) -> None:
return h.hexdigest()


def _compute_busyness(commits: list[CommitEntry]) -> BusynessThresholds:
"""Repo-relative per-day commit-count thresholds. avg = median commits/day
(over days with >= 1 commit); busy = 75th percentile, clamped to avg+1 so
the three bands stay distinct. Both the scene tree-color gradient and the
commit pane's label read these, so a busy day looks consistent in both.
Returns {avg:1, busy:1} for an empty history so consumers needn't guard."""
if not commits:
return {"avg": 1, "busy": 1}
per_day: dict[str, int] = {}
for c in commits:
per_day[c["date"]] = per_day.get(c["date"], 0) + 1
counts = sorted(per_day.values())

def _quantile(p: float) -> int:
return counts[min(len(counts) - 1, int(len(counts) * p))]

avg = _quantile(0.5)
busy = max(_quantile(0.75), avg + 1)
return {"avg": avg, "busy": busy}


def _wrap_manifest(
root_abs: str, tree: DirNode, sig: Any, tree_signature: str,
repo_info: RepoInfo, commits: list[CommitEntry],
Expand All @@ -1048,6 +1102,7 @@ def _wrap_manifest(
has placeholder lines/binary set by _force_skeleton_placeholders;
final has the real per-file metadata). The envelope shape is the
same either way."""
_annotate_same_day_totals(commits)
return {
"root": root_abs,
"scanned_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
Expand All @@ -1056,9 +1111,22 @@ def _wrap_manifest(
"tree": tree,
"repo": repo_info,
"commits": commits,
"busyness": _compute_busyness(commits),
}


def _annotate_same_day_totals(commits: list[CommitEntry]) -> None:
"""In-place: set each commit's same_day_total to the number of commits
sharing its calendar date. A derived aggregate (like busyness), so it's
baked at wrap time rather than during git collection — both the commit
pane's badge and the scene tree-color read this one field (#35)."""
per_day: dict[str, int] = {}
for c in commits:
per_day[c["date"]] = per_day.get(c["date"], 0) + 1
for c in commits:
c["same_day_total"] = per_day[c["date"]]


def _force_skeleton_placeholders(node: DirNode | FileNode) -> None:
"""In-place: set every FileNode under `node` to lines=1, binary=False
so the skeleton renders with uniform-height buildings."""
Expand Down
8 changes: 5 additions & 3 deletions api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,13 +748,15 @@ def _run_scan() -> None:
try:
_stream_events(handler, _events(), cancel_event)

# Only cache on successful completion AND only when use_cache.
# Always write the cache on a successful scan — `use_cache` only
# controls whether we READ from it. A skip-cache (no_cache) scan still
# persists its fresh result, so the next normal load is served the
# up-to-date manifest instead of a stale one.
final_manifest = state["final_manifest"]
scan_target = state["scan_target"]
sig = state["sig"]
if (
use_cache
and final_manifest is not None
final_manifest is not None
and scan_target is not None
and sig is not None
):
Expand Down
76 changes: 62 additions & 14 deletions api/tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,37 @@ def test_hit_on_matching_head(self) -> None:
result = cache_mod.cache_load_git_history(root, "abc123")
self.assertEqual(result, (created, modified, []))

def test_round_trips_full_commit_entries(self) -> None:
# The loader must reconstruct the WHOLE CommitEntry — authors +
# subject included. A previous bug dropped them, so commits loaded
# from a warm cache had no `authors`, crashing the fireflies
# consumer (`for author of c.authors`). Round-trip a populated commit.
root = Path("/some/repo")
commits = [
{
"date": "2024-01-01",
"files": 3,
"sha": "a" * 40,
"authors": ["Alice", "Bob"],
"subject": "Initial commit",
},
{
"date": "2024-01-02",
"files": 1,
"sha": "b" * 40,
"authors": [],
"subject": "Empty-authors edge case",
},
]
cache_mod.cache_save_git_history(root, "head1", {}, {}, commits)
result = cache_mod.cache_load_git_history(root, "head1")
assert result is not None
_, _, loaded = result
self.assertEqual(loaded, commits)
# Every loaded commit carries an iterable authors list.
for c in loaded:
self.assertIsInstance(c["authors"], list)

def test_miss_on_different_head(self) -> None:
root = Path("/some/repo")
cache_mod.cache_save_git_history(root, "abc123", {}, {}, [])
Expand Down Expand Up @@ -178,8 +209,10 @@ def test_git_history_cache_round_trips_commits(self):
"""Round-trip a small commits list through the cache."""
root = Path("/some/repo")
commits: list[CommitEntry] = [
{"date": "2024-01-01", "files": 3, "sha": "a" * 40},
{"date": "2024-02-15", "files": 7, "sha": "b" * 40},
{"date": "2024-01-01", "files": 3, "sha": "a" * 40,
"authors": ["Alice"], "subject": "first"},
{"date": "2024-02-15", "files": 7, "sha": "b" * 40,
"authors": ["Bob", "Carol"], "subject": "second"},
]
cache_mod.cache_save_git_history(
root, head_sha="abc",
Expand Down Expand Up @@ -208,26 +241,41 @@ def test_git_history_cache_drops_malformed_commits(self):
"created": {},
"modified": {},
"commits": [
{"date": "2024-01-01", "files": 3, "sha": sha_a}, # valid
{"date": "2024-01-01", "files": 3, "sha": sha_a,
"authors": ["Alice"], "subject": "ok"}, # valid
"not a dict", # dropped: not a dict
{"date": 12345, "files": 5, "sha": sha_a}, # dropped: date not str
{"date": "2024-02-01", "sha": sha_a}, # dropped: missing files
{"date": "2024-03-01", "files": True, "sha": sha_a}, # dropped: files is bool
{"files": 4, "sha": sha_a}, # dropped: missing date
{"date": "2024-04-01", "files": 7}, # dropped: missing sha
{"date": "2024-05-01", "files": 2, "sha": "short"}, # dropped: sha too short
{"date": "2024-05-15", "files": 4, "sha": "Z" * 40}, # sha contains non-hex characters
{"date": "2024-06-01", "files": 1, "sha": sha_b}, # valid
{"date": 12345, "files": 5, "sha": sha_a,
"authors": [], "subject": "x"}, # dropped: date not str
{"date": "2024-02-01", "sha": sha_a,
"authors": [], "subject": "x"}, # dropped: missing files
{"date": "2024-03-01", "files": True, "sha": sha_a,
"authors": [], "subject": "x"}, # dropped: files is bool
{"files": 4, "sha": sha_a,
"authors": [], "subject": "x"}, # dropped: missing date
{"date": "2024-04-01", "files": 7,
"authors": [], "subject": "x"}, # dropped: missing sha
{"date": "2024-05-01", "files": 2, "sha": "short",
"authors": [], "subject": "x"}, # dropped: sha too short
{"date": "2024-05-15", "files": 4, "sha": "Z" * 40,
"authors": [], "subject": "x"}, # dropped: non-hex sha
{"date": "2024-07-01", "files": 1, "sha": sha_a,
"authors": "Alice", "subject": "x"}, # dropped: authors not a list
{"date": "2024-08-01", "files": 1, "sha": sha_a,
"authors": ["Alice"]}, # dropped: missing subject
{"date": "2024-06-01", "files": 1, "sha": sha_b,
"authors": ["Bob"], "subject": "ok2"}, # valid
],
}), encoding="utf-8")
loaded = cache_mod.cache_load_git_history(root, "abc")
self.assertIsNotNone(loaded)
assert loaded is not None
_created, _modified, commits = loaded
# Only the two well-formed entries survive.
# Only the two well-formed entries survive (with authors + subject).
self.assertEqual(commits, [
{"date": "2024-01-01", "files": 3, "sha": sha_a},
{"date": "2024-06-01", "files": 1, "sha": sha_b},
{"date": "2024-01-01", "files": 3, "sha": sha_a,
"authors": ["Alice"], "subject": "ok"},
{"date": "2024-06-01", "files": 1, "sha": sha_b,
"authors": ["Bob"], "subject": "ok2"},
])

def test_git_history_rejects_old_version(self):
Expand Down
Loading