From d5ab8367f438730b2d2cb33993195a34f3c4320d Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 12:49:49 -0400 Subject: [PATCH 01/37] Keep the loading overlay up until the world is actually on screen Two ways it lied. It lifted as soon as the heights landed, while git history was still streaming: history is where commits come from, and commits are the trees, so the reveal handed you a city that then grew trees under your eyes. And re-opening the project you were just in never streams at all, so nothing raised it: the manifest was already in hand, the canvas had been thrown away with the route, and the rebuild that followed ran behind a blank stage. So the overlay's lifetime is one rule now: up while a world is coming, down once that world is ON SCREEN. The stream ending is only the middle of that wait. A build with nothing on screen behind it raises the overlay on its own, with the build row alone, since nothing was fetched and the fetch rows would be rows for work nobody did. Timeline's reveal waits for the same frame rather than one rAF past the pack. "Reading history" is its own row: the git walk is the longest stage of a cold scan, and the list would otherwise sit on Sketching layout through minutes of it. CITY_ON_SCREEN is what "on screen" means, and unmounting the canvas clears it. The landing's wallpaper builds through the same pipeline but is not the world, so it reports through a silent BuildReporter: a finished wallpaper claiming the world is up outlived the canvas it was drawn on, which is what made the missing overlay intermittent. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/city/City.tsx | 22 +++- app/src/city/index.ts | 11 +- app/src/city/state/index.ts | 24 ++-- .../loading/LoadingOverlay/LoadingOverlay.tsx | 8 +- .../LoadingProgress/LoadingProgress.tsx | 13 +- app/src/constants/progress.ts | 33 +++-- app/src/hooks/useTimelineMode.ts | 9 +- app/src/router/viewBinding.ts | 4 +- app/src/state/stores/manifest.ts | 2 +- app/src/state/stores/progress.ts | 114 ++++++++++++++---- app/src/types/ui.ts | 4 +- app/tests/_helpers/cityFixtures.ts | 7 +- app/tests/city/applyManifestScenic.test.ts | 7 +- app/tests/city/components/trees/index.test.ts | 4 +- .../city/state/applyManifestProgress.test.ts | 23 +++- .../city/state/applyManifestReuse.test.ts | 13 +- app/tests/hooks/useTimelineMode.test.ts | 42 ++++++- app/tests/state/stores/progress.test.ts | 95 ++++++++++----- 18 files changed, 324 insertions(+), 111 deletions(-) diff --git a/app/src/city/City.tsx b/app/src/city/City.tsx index 9ecd6f40..f3e8b099 100644 --- a/app/src/city/City.tsx +++ b/app/src/city/City.tsx @@ -11,7 +11,13 @@ import { CameraMode } from '@/city/render/cameraRig'; import { attachSettingsReactions } from '@/state/settings/reactions'; import { BACKDROP_HANDLE, SCENE_HANDLE } from '@/city/sceneHandle'; import { MANIFEST } from '@/state/stores/manifest'; -import { markRebuilding, markError } from '@/state/stores/progress'; +import { + markRebuilding, + markError, + markSceneGone, + WORLD_BUILD_REPORTER, + SILENT_BUILD_REPORTER, +} from '@/state/stores/progress'; import { TIMELINE_MODE } from '@/state/stores/timeline'; import { reapplyTimelineScene } from '@/hooks/useTimelineMode'; import type { Manifest } from '@/types'; @@ -42,8 +48,12 @@ export function City({ variant = CityVariant.Scene }: CityProps = {}) { // Start empty; the apply-effect below paints the first manifest. The variant // is also what the camera is FOR, so the rig opens on the right pose itself. + const isWorld = variant === CityVariant.Scene; createCity(canvas, { - cameraMode: variant === CityVariant.Backdrop ? CameraMode.Backdrop : CameraMode.Project, + cameraMode: isWorld ? CameraMode.Project : CameraMode.Backdrop, + // Same pipeline, but the wallpaper is not the project you opened: its + // finish must not read as the world being on screen. + report: isWorld ? WORLD_BUILD_REPORTER : SILENT_BUILD_REPORTER, }) .then((handle) => { // Unmounted before the async build resolved: dispose the orphan now, or @@ -96,8 +106,12 @@ export function City({ variant = CityVariant.Scene }: CityProps = {}) { city?.dispose(); city = null; // Only its own slot: clearing the other would strand the city still up. - if (variant === CityVariant.Scene) SCENE_HANDLE.value = null; - else BACKDROP_HANDLE.value = null; + if (variant === CityVariant.Scene) { + SCENE_HANDLE.value = null; + // Nothing is on screen any more, so the remount's rebuild is a load + // with a world to wait for, not a repaint under one. + markSceneGone(); + } else BACKDROP_HANDLE.value = null; }; }, []); diff --git a/app/src/city/index.ts b/app/src/city/index.ts index 944b00f0..3adf0dd6 100644 --- a/app/src/city/index.ts +++ b/app/src/city/index.ts @@ -9,7 +9,7 @@ import { effect, untracked } from '@preact/signals'; import type { Manifest, RangeStat } from '@/types'; import { CURRENT_SOURCE_KEY } from '@/state/stores/source'; import { MANIFEST } from '@/state/stores/manifest'; -import { markIdle } from '@/state/stores/progress'; +import { WORLD_BUILD_REPORTER, type BuildReporter } from '@/state/stores/progress'; import { TIMELINE_MODE, SCRUB_DRAGGING, SCRUB_POS } from '@/state/stores/timeline'; import { registerShaderChunks } from './utils/shaders/registerShaderChunks'; @@ -48,7 +48,10 @@ import { registerRenderer as registerFacadePanelRenderer } from './components/bu export async function createCity( canvas: HTMLCanvasElement, - { cameraMode = CameraMode.Project }: { cameraMode?: CameraMode } = {} + { + cameraMode = CameraMode.Project, + report = WORLD_BUILD_REPORTER, + }: { cameraMode?: CameraMode; report?: BuildReporter } = {} ): Promise { // Must precede any ShaderMaterial so #include directives resolve. registerShaderChunks(); @@ -73,7 +76,7 @@ export async function createCity( // Both off-thread build workers, owned here and handed to the store that runs // the build. Lazy: neither spawns until its first compute(). const treePlacementClient = createTreePlacementClient(); - const cityState = createCityState(layoutClient, treePlacementClient); + const cityState = createCityState(layoutClient, treePlacementClient, report); // Pulled off cityState for the City handle; components never wire into // these — they rebuild reactively off cityState's signals. const { applyManifest, buildStagesFor, invalidateLayoutCache } = cityState; @@ -160,7 +163,7 @@ export async function createCity( void buildings.whenSettled().then(() => { // Two frames: render() only issues the GL commands, and the pixels land // a compositor pass later. - requestAnimationFrame(() => requestAnimationFrame(() => markIdle())); + requestAnimationFrame(() => requestAnimationFrame(() => report.markIdle())); }); }); }); diff --git a/app/src/city/state/index.ts b/app/src/city/state/index.ts index 22a12725..b3edee80 100644 --- a/app/src/city/state/index.ts +++ b/app/src/city/state/index.ts @@ -6,12 +6,7 @@ import { signal, computed, batch, type Signal, type ReadonlySignal } from '@prea import * as THREE from 'three'; import { FOOTPRINT } from '@/state/settings/fields/footprint'; import { TREES } from '@/state/settings/fields/trees'; -import { - beginBuild, - enterBuildStage, - markDecorating, - setBuildStagePercent, -} from '@/state/stores/progress'; +import { type BuildReporter } from '@/state/stores/progress'; import { BuildStage } from '@/constants/progress'; import { StreetAxis } from '@/types'; import type { Building, CityBbox, CityLayout, Manifest, Street } from '@/types'; @@ -135,7 +130,10 @@ function sceneBboxOf(b: THREE.Box3): CityBbox { export function createCityState( layoutClient: ReturnType, - treePlacementClient: TreePlacementClient + treePlacementClient: TreePlacementClient, + // Whose build this is: the world's, or a wallpaper's that nobody is waiting + // for (see BuildReporter). + report: BuildReporter ): CityState { const manifest = signal(null); const layout = signal(null); @@ -258,8 +256,8 @@ export function createCityState( // The readout's denominator, decided before the first stage starts: a // fixed count would promise a stage this apply is not going to run. const own = buildStagesFor(newManifest); - beginBuild([...leadingStages, ...own]); - enterBuildStage(own[0]); + report.beginBuild([...leadingStages, ...own]); + report.enterBuildStage(own[0]); // The manifest fan-out and the atlas walk below run before anything can // repaint, so the row would name them only once they were over. await nextPaint(); @@ -278,7 +276,7 @@ export function createCityState( } } - enterBuildStage(BuildStage.Layout); + report.enterBuildStage(BuildStage.Layout); const reusedLayout = shouldReuse ? layout.peek() : null; let newLayout: CityLayout; // Full envelope, not `.tree`: the worker contract stays typed against @@ -287,7 +285,7 @@ export function createCityState( newLayout = await layoutClient.compute(newManifest, reusedLayout, (percent) => { // A superseded apply's worker keeps posting until it is told to stop; // its percent must not walk over the live build's readout. - if (myGeneration === generation) setBuildStagePercent(percent); + if (myGeneration === generation) report.setBuildStagePercent(percent); }); } catch (err) { if (err instanceof Error && err.message === 'superseded') return; @@ -297,7 +295,7 @@ export function createCityState( // The batch below holds the main thread for seconds on a big repo: hand the // browser a frame first, so the row naming that work paints before it. - enterBuildStage(BuildStage.Assemble); + report.enterBuildStage(BuildStage.Assemble); await nextPaint(); if (myGeneration !== generation) return; @@ -305,7 +303,7 @@ export function createCityState( // to the component that draws them: the batch below publishes a whole city. let newPlacements: TreePlacement[] | null = null; if (TREES.peek().ENABLED) { - markDecorating(); + report.markDecorating(); const newBbox = cityBbox(newLayout, footprintHalo.peek()); try { newPlacements = await treePlacementClient.compute( diff --git a/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx b/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx index 9b52ac9f..1e4604c0 100644 --- a/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx +++ b/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx @@ -1,7 +1,7 @@ -// components/loading/LoadingOverlay/LoadingOverlay.tsx — Full-viewport centered progress, mounted once -// by App.tsx and driven by the LOADING_OVERLAY signal. Narrow role by design: a -// load driven from renders its OWN inline progress and this -// suppresses itself (below), so two full-viewport surfaces never stack. +// components/loading/LoadingOverlay/LoadingOverlay.tsx — full-viewport centered +// progress, mounted by CityView and driven entirely by the LOADING_OVERLAY +// signal: what raises it and what takes it down is the store's call (see +// attachLoadReaction), so this renders and nothing more. import './LoadingOverlay.css'; import { LOADING_OVERLAY } from '@/state/stores/progress'; diff --git a/app/src/components/loading/LoadingProgress/LoadingProgress.tsx b/app/src/components/loading/LoadingProgress/LoadingProgress.tsx index 5c1f4db7..ec9fbcf5 100644 --- a/app/src/components/loading/LoadingProgress/LoadingProgress.tsx +++ b/app/src/components/loading/LoadingProgress/LoadingProgress.tsx @@ -1,7 +1,6 @@ -// components/loading/LoadingProgress/LoadingProgress.tsx — the shared loading column: -// repo label + spinner + current-step status + the stepped progress list. -// Rendered by both the app-level LoadingOverlay (deep-link cold boot) and -// ProjectsView's inline progress, so the phase→step mapping has one definition. +// components/loading/LoadingProgress/LoadingProgress.tsx — the loading column: +// repo label + spinner + current-step status + the stepped progress list. Told +// which rows to draw and which one is live; what those mean is the store's. import './LoadingProgress.css'; import { BranchPill } from '@/components/sources/BranchPill/BranchPill'; @@ -20,13 +19,11 @@ export interface LoadingProgressProps { activeStep: LoadingStep; kind: SourceKind | null; branch?: string | null; - // Trailing per-step progress text (clone %, scanned file count). Only the - // overlay flow supplies these; ProjectsView omits them. + // Trailing per-step progress text (clone %, scanned file count, build %). stepTails?: Partial>; // Custom step list (e.g. Timeline-mode entry). Defaults to LOADING_STEPS. steps?: readonly LoadingStep[]; - // Aborts the load and returns to the project list. Each surface wires the - // routing (ProjectsView is already the list; the overlay opens it). + // Aborts the wait and returns to the project list. onCancel: () => void; } diff --git a/app/src/constants/progress.ts b/app/src/constants/progress.ts index faf97a89..b800fc14 100644 --- a/app/src/constants/progress.ts +++ b/app/src/constants/progress.ts @@ -4,7 +4,7 @@ import { ScanPhase } from '@/api/manifest'; import { SourceKind } from '@/utils/sources'; -import { TimelineStage } from '@/types'; +import { TimelineStage, type Manifest } from '@/types'; // ── The overlay's rows ─────────────────────────────────────────────── @@ -13,6 +13,9 @@ export enum LoadingStep { Cloning = 'cloning', Scanning = 'scanning', Skeleton = 'skeleton', + /** The server walking git log, once the heights have landed: the longest + * stage of a cold scan, and the one the city's trees come out of. */ + History = 'history', Building = 'building', // Timeline-mode entry, one row per stage of the history stream: the blob // backfill, the commit walk, then blob resolution. @@ -21,16 +24,21 @@ export enum LoadingStep { TimelineBlobs = 'timeline-blobs', } -// Display order. 'skeleton' paints placeholders while the server resolves -// per-file metadata; 'building' tweens in the real heights and ends every list. +// Display order. 'history' is the git walk after the heights, and 'building' +// raises the final city, trees and all, so it ends every list. export const LOADING_STEPS: readonly LoadingStep[] = [ LoadingStep.Resolving, LoadingStep.Cloning, LoadingStep.Scanning, LoadingStep.Skeleton, + LoadingStep.History, LoadingStep.Building, ]; +// A rebuild with no stream behind it: a remount packs the manifest already in +// hand, so every fetch row above would be a row for work nobody did. +export const BUILD_ONLY_STEPS: readonly LoadingStep[] = [LoadingStep.Building]; + // Timeline's own list. Reuses LoadingStep.Building rather than inventing // a second label for the same act. export const TIMELINE_LOADING_STEPS: readonly LoadingStep[] = [ @@ -54,6 +62,7 @@ export const LOADING_STEP_LABELS: Record = { [LoadingStep.Cloning]: 'Cloning', [LoadingStep.Scanning]: 'Scanning files', [LoadingStep.Skeleton]: 'Sketching layout', + [LoadingStep.History]: 'Reading history', [LoadingStep.Building]: 'Building city', [LoadingStep.TimelineFetch]: 'Fetching history', [LoadingStep.TimelineHistory]: 'Walking commits', @@ -97,16 +106,22 @@ export function firstStepFor(steps: readonly LoadingStep[], kind: SourceKind | n return steps.find((step) => stepRuns(step, kind)) ?? steps[0]; } -/** Scan phase to step, by source kind: local skips resolving and cloning. One - * definition, shared by the overlay reactions and the inline progress. */ -export function stepForPhase(phase: ScanPhase | null, kind: SourceKind): LoadingStep { +/** Scan phase to step, by source kind. `applied` is the pending of the manifest + * the event carried: the only thing telling the two partials apart. */ +export function stepForPhase( + phase: ScanPhase | null, + kind: SourceKind, + applied?: Manifest['pending'] +): LoadingStep { switch (phase) { case ScanPhase.CloneProgress: return LoadingStep.Cloning; case ScanPhase.ScanProgress: return LoadingStep.Scanning; case ScanPhase.PartialManifest: - return LoadingStep.Skeleton; + // The skeleton's placeholder heights are what Sketching layout draws. Once + // metadata has landed the only stage left on the server is the git walk. + return applied && !applied.includes('metadata') ? LoadingStep.History : LoadingStep.Skeleton; case ScanPhase.CompleteManifest: return LoadingStep.Building; default: @@ -145,8 +160,8 @@ export enum BuildStage { Layout = 'layout', /** The batch that swaps manifest + layout, and the mesh rebuilds it fires. */ Assemble = 'assemble', - /** The deferred pass: tree placement off-thread, then its meshes. Runs with - * the city already up, and outlives the overlay in Live. */ + /** The deferred pass: tree placement off-thread, then its meshes. The last + * stage of every build, and the overlay waits it out. */ Decorate = 'decorate', } diff --git a/app/src/hooks/useTimelineMode.ts b/app/src/hooks/useTimelineMode.ts index 8bed3bb6..03ab76c9 100644 --- a/app/src/hooks/useTimelineMode.ts +++ b/app/src/hooks/useTimelineMode.ts @@ -24,6 +24,7 @@ import { setLoadingStep, setLoadingStepTail, hideLoadingOverlay, + whenCityOnScreen, setLoadingCancel, PENDING_SOURCE_LABEL, } from '@/state/stores/progress'; @@ -177,10 +178,10 @@ export async function loadTimelineSource({ // commit rests on it, and anything else opens at the present. setScrubPos(scrubTarget(bundle, commit, inPlace)); if (overlay) { - // Hold the overlay through the union city's first painted frame, then reveal. - requestAnimationFrame(() => { - hideLoadingOverlay(); - }); + // The pack returns before its meshes are drawn: wait for the frame that + // carries them, or the reveal lands on a half-built city. + await whenCityOnScreen(); + hideLoadingOverlay(); } } catch (err) { if (cancelled) return; // user cancel already aborted the fetch + restored live diff --git a/app/src/router/viewBinding.ts b/app/src/router/viewBinding.ts index 136b6c1b..6c403a39 100644 --- a/app/src/router/viewBinding.ts +++ b/app/src/router/viewBinding.ts @@ -10,7 +10,7 @@ import { setRouteParams, ROUTE_PARAMS, ROUTE_PATH, type NavigateOptions } from ' import { parseSelection, selectionParam } from './viewParams'; import { ROUTES } from './paths'; import { CURRENT_SOURCE } from '@/state/stores/source'; -import { BUILT_MANIFEST } from '@/state/stores/progress'; +import { CITY_ON_SCREEN } from '@/state/stores/progress'; import { goToPath, goToCommit, clearSelection } from '@/city/sceneHandle'; import { FocusMode } from '@/city/render/cameraRig'; import { @@ -79,7 +79,7 @@ function installViewFollow(followed: Signal): () => void { const params = ROUTE_PARAMS.value; const onCity = ROUTE_PATH.value === ROUTES.CITY; const source = CURRENT_SOURCE.value; - const built = BUILT_MANIFEST.value !== null; + const built = CITY_ON_SCREEN.value; // Nothing to select in, and no bundle to scrub, until there is a city. if (!onCity || !source || !built) return; diff --git a/app/src/state/stores/manifest.ts b/app/src/state/stores/manifest.ts index 726415c0..a7dfda37 100644 --- a/app/src/state/stores/manifest.ts +++ b/app/src/state/stores/manifest.ts @@ -1,7 +1,7 @@ // state/stores/manifest.ts — the manifest of the project you opened, at HEAD. // The one the fetch layer writes and everything else reads. Two neighbours // answer the same question differently: `scrub.ts` for what a Timeline position -// implies, `build.ts` for whether the world is currently being built from this. +// implies, `progress.ts` for how far the world built from this has got. import { signal } from '@preact/signals'; import type { Manifest, DirNode } from '@/types'; diff --git a/app/src/state/stores/progress.ts b/app/src/state/stores/progress.ts index 2191e137..ec2185c5 100644 --- a/app/src/state/stores/progress.ts +++ b/app/src/state/stores/progress.ts @@ -3,7 +3,7 @@ // on top of both. The overlay is a reduction over the other two (a running max // over rows, the last tail each saw), which is why it needs the driver below. -import { signal, effect } from '@preact/signals'; +import { signal, computed, effect } from '@preact/signals'; import { SourceKind } from '@/utils/sources'; import { ScanPhase, CloneStage } from '@/api/manifest'; import type { Manifest } from '@/types'; @@ -13,11 +13,13 @@ import { type BuildProgress, LoadingStep, LOADING_STEPS, + BUILD_ONLY_STEPS, firstStepFor, stepForPhase, transferTail, } from '@/constants/progress'; import { MANIFEST, type ManifestValue } from './manifest'; +import { SOURCE_INFO } from './source'; import type { LoadingOverlayShowOpts, LoadingOverlayState } from '@/types/ui'; // ── What the server is doing ───────────────────────────────────────── @@ -70,6 +72,10 @@ export const REBUILD_STATUS = signal(RebuildStatus.Pending); * that aims the camera at a node needs that node to exist. */ export const BUILT_MANIFEST = signal(null); +/** Whether a finished city is on screen RIGHT NOW: set when one is presented, + * cleared when the canvas goes away. Not "has anything ever been built". */ +export const CITY_ON_SCREEN = computed(() => BUILT_MANIFEST.value !== null); + /** Error message from the most recent failed rebuild; null when idle/success. */ export const LAST_REBUILD_ERROR = signal(null); @@ -95,8 +101,8 @@ export function markRebuilding(): void { BUILD_PROGRESS.value = null; } -// Decoration is the build's last stage, not the end of it: Timeline's overlay -// stays up through the tree pass, and the trees land at the END of that stage. +// Decoration is the build's last stage, not the end of it: the overlay stays up +// through the tree pass, and the trees land at the END of that stage. export function markDecorating(): void { REBUILD_STATUS.value = RebuildStatus.Decorating; enterBuildStage(BuildStage.Decorate); @@ -113,6 +119,36 @@ export function markIdle(): void { LAST_UPDATED_AT.value = Date.now(); } +/** The canvas went away (leaving `/city` unmounts it), so nothing is on screen: + * the remount's rebuild is a load with a world to wait for. */ +export function markSceneGone(): void { + REBUILD_STATUS.value = RebuildStatus.Pending; + BUILT_MANIFEST.value = null; + REBUILD_DETAIL.value = null; + BUILD_PROGRESS.value = null; +} + +/** Resolves once the build in flight has a frame on screen, or has failed: a + * city that will never paint must not strand the caller waiting for it. */ +export function whenCityOnScreen(): Promise { + return new Promise((resolve) => { + let stop: (() => void) | null = null; + let settled = false; + stop = effect(() => { + const status = REBUILD_STATUS.value; + if (status !== RebuildStatus.Idle && status !== RebuildStatus.Error) return; + if (settled) return; + settled = true; + // Out of the effect's own run: `stop` is still being assigned when this + // resolves on the first pass. + queueMicrotask(() => { + stop?.(); + resolve(); + }); + }); + }); +} + export function markError(err: unknown): void { REBUILD_STATUS.value = RebuildStatus.Error; // Logged with the stack, where a developer can use it. The UI shows a generic @@ -155,6 +191,37 @@ export function setBuildStagePercent(percent: number): void { BUILD_PROGRESS.value = { ...prev, percent }; } +// ── Who reports the world's build ──────────────────────────────────── + +/** The status writes a city makes as it assembles. Two cities build over one + * session, and only the project you opened is the world these describe. */ +export interface BuildReporter { + beginBuild(stages: readonly BuildStage[]): void; + enterBuildStage(stage: BuildStage): void; + setBuildStagePercent(percent: number): void; + markDecorating(): void; + markIdle(): void; +} + +/** The project you opened: its build is the wait every readout is about. */ +export const WORLD_BUILD_REPORTER: BuildReporter = { + beginBuild, + enterBuildStage, + setBuildStagePercent, + markDecorating, + markIdle, +}; + +/** Scenery: nobody is waiting for it, and a finished wallpaper claiming the + * world is on screen outlives the canvas it was drawn on. */ +export const SILENT_BUILD_REPORTER: BuildReporter = { + beginBuild: () => {}, + enterBuildStage: () => {}, + setBuildStagePercent: () => {}, + markDecorating: () => {}, + markIdle: () => {}, +}; + // ── What the overlay shows ─────────────────────────────────────────── /** Repo name in the loading overlay's header, shown before the manifest lands. @@ -219,7 +286,7 @@ export function setLoadingStepTail(step: LoadingStep, tail: string | null): void // ── The Live driver ────────────────────────────────────────────────── export function attachOverlayDriver(): () => void { - const stops = [attachScanReaction(), attachBuildReaction()]; + const stops = [attachLoadReaction(), attachBuildReaction()]; return () => stops.forEach((stop) => stop()); } @@ -231,7 +298,9 @@ function attachBuildReaction(): () => void { }); } -function attachScanReaction(): () => void { +// The overlay's whole lifetime: up while a world is coming, down once that +// world is ON SCREEN. The stream ending is only the middle of that wait. +function attachLoadReaction(): () => void { let overlayUp = false; // How far down the list this load has got. A row that lights up again after a // later one reads as the whole load starting over. @@ -243,19 +312,31 @@ function attachScanReaction(): () => void { reached = index; setLoadingStep(step); }; + // Nothing was fetched, so the build row is the only honest one. An overlay + // already up belongs to whoever raised it (Timeline brings its own list). + const showForBuild = (): void => { + if (LOADING_OVERLAY.peek().visible) return; + PENDING_SOURCE_LABEL.value = SOURCE_INFO.peek().label || null; + showLoadingOverlay({ kind: null, steps: BUILD_ONLY_STEPS }); + overlayUp = true; + reached = -1; + }; return effect(() => { const p = SCAN_PROGRESS.value; - // The stream finishing is not the city appearing: Idle now means a frame of - // it has been presented, so every earlier status keeps the overlay up. + // The stream finishing is not the city appearing: Idle means a frame of it + // has been presented, so every earlier status keeps the overlay up. const status = REBUILD_STATUS.value; const building = status === RebuildStatus.Rebuilding || status === RebuildStatus.Decorating; + const onScreen = CITY_ON_SCREEN.value; const hide = () => { if (overlayUp) hideLoadingOverlay(); overlayUp = false; }; if (!p) { - if (building) { - // Stream done, city still assembling — keep the overlay on "Building". + // A build is all that's left to wait for: this overlay's own, or one for + // a city not up yet. A rebuild UNDER one is the footer's to report. + if (building && (overlayUp || !onScreen)) { + if (!overlayUp) showForBuild(); advance(LoadingStep.Building); return; } @@ -264,19 +345,6 @@ function attachScanReaction(): () => void { } // A load's first event: a new list, so it starts from the top again. if (p.phase === null) reached = -1; - // The skeleton's own build belongs to Sketching layout: placeholder heights - // are what that row draws. Building city is the real ones going up. - const sketching = p.appliedPending?.includes('metadata') ?? false; - if (p.appliedPending && !sketching) { - // Real heights: only git history is still streaming, and it adds trees to - // an already-correct city. Lift as soon as that paint lands. - if (!building) { - hide(); - return; - } - advance(LoadingStep.Building); - return; - } if (!overlayUp) { // null→non-null: show the overlay at the kind-based initial step // (Resolving for git, Scanning for local). @@ -284,7 +352,7 @@ function attachScanReaction(): () => void { overlayUp = true; reached = LOADING_STEPS.indexOf(firstStepFor(LOADING_STEPS, p.kind)); } - advance(stepForPhase(p.phase, p.kind)); + advance(stepForPhase(p.phase, p.kind, p.appliedPending)); if (p.phase === ScanPhase.CloneProgress) { // A heartbeat during the silent promisor blob fetch has no percent at // all, and shows the working tree growing on disk instead. diff --git a/app/src/types/ui.ts b/app/src/types/ui.ts index a0dab4a5..3e77cd8b 100644 --- a/app/src/types/ui.ts +++ b/app/src/types/ui.ts @@ -34,7 +34,9 @@ export interface SourceError { /** Options for showing the loading overlay. */ export interface LoadingOverlayShowOpts { - kind: SourceKind; + /** Null when no source was fetched at all: a build-only overlay has no + * clone or scan rows to decide the shape of. */ + kind: SourceKind | null; branch?: string; /** Custom step list (e.g. Timeline-mode entry). Defaults to LOADING_STEPS. */ steps?: readonly LoadingStep[]; diff --git a/app/tests/_helpers/cityFixtures.ts b/app/tests/_helpers/cityFixtures.ts index b5f44b95..d5c52301 100644 --- a/app/tests/_helpers/cityFixtures.ts +++ b/app/tests/_helpers/cityFixtures.ts @@ -10,6 +10,7 @@ import type { Picker } from '@/city/interaction/picker'; import { TREES } from '@/state/settings/fields/trees'; import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; import { createCityState, type CityState } from '@/city/state'; +import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; import { commits } from './commits'; // A no-op layout client, for tests that never call applyManifest and so never @@ -27,7 +28,11 @@ export function stubPlacementClient(placements: unknown[] = []) { /** cityState with no-op build workers, for tests that don't drive applyManifest. */ export function makeCityState(): CityState { - return createCityState(STUB_LAYOUT_CLIENT as never, stubPlacementClient() as never); + return createCityState( + STUB_LAYOUT_CLIENT as never, + stubPlacementClient() as never, + WORLD_BUILD_REPORTER + ); } /** A canvas whose clientWidth/clientHeight track a mutable `size`, so a test can diff --git a/app/tests/city/applyManifestScenic.test.ts b/app/tests/city/applyManifestScenic.test.ts index 2b301f70..77a350ed 100644 --- a/app/tests/city/applyManifestScenic.test.ts +++ b/app/tests/city/applyManifestScenic.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { createCityState } from '@/city/state'; +import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; import { createStreets } from '@/city/components/streets'; import { NodeKind, StreetAxis } from '@/types'; import type { CityLayout, DateRanges, Manifest, Street } from '@/types'; @@ -70,7 +71,11 @@ describe('cityState.applyManifest — scenic reactivity parity', () => { }) as unknown as CityLayout ); - const cityState = createCityState(layoutClient as never, stubPlacementClient() as never); + const cityState = createCityState( + layoutClient as never, + stubPlacementClient() as never, + WORLD_BUILD_REPORTER + ); const streets = createStreets(makeSceneContext(cityState)); disposers.push(() => streets.dispose()); diff --git a/app/tests/city/components/trees/index.test.ts b/app/tests/city/components/trees/index.test.ts index a654e2a5..2599a9ab 100644 --- a/app/tests/city/components/trees/index.test.ts +++ b/app/tests/city/components/trees/index.test.ts @@ -8,6 +8,7 @@ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { createTrees } from '@/city/components/trees'; import { createCityState } from '@/city/state'; +import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; import { commitTarget, makeCityState, @@ -118,7 +119,8 @@ describe('createTrees() component door', () => { it('renders the placements an apply published, in the same flush as the city', async () => { const cityState = createCityState( layoutClientFor(TREE_LAYOUT) as never, - stubPlacementClient(PLACEMENTS) as never + stubPlacementClient(PLACEMENTS) as never, + WORLD_BUILD_REPORTER ); const { ctx } = makePickableSceneContext(cityState); trees = createTrees(ctx); diff --git a/app/tests/city/state/applyManifestProgress.test.ts b/app/tests/city/state/applyManifestProgress.test.ts index 325629f4..02962ebf 100644 --- a/app/tests/city/state/applyManifestProgress.test.ts +++ b/app/tests/city/state/applyManifestProgress.test.ts @@ -6,7 +6,7 @@ import { stubPlacementClient } from '../../_helpers/cityFixtures'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { effect } from '@preact/signals'; import { createCityState } from '@/city/state'; -import { BUILD_PROGRESS } from '@/state/stores/progress'; +import { BUILD_PROGRESS, WORLD_BUILD_REPORTER } from '@/state/stores/progress'; import { buildStageTail } from '@/constants/progress'; import { NodeKind } from '@/types'; import type { CityLayout, DateRanges, Manifest } from '@/types'; @@ -75,7 +75,11 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => }); it('walks the stages it is going to run', async () => { - const state = createCityState(fakeLayoutClient() as never, stubPlacementClient() as never); + const state = createCityState( + fakeLayoutClient() as never, + stubPlacementClient() as never, + WORLD_BUILD_REPORTER + ); const tails = await tailsDuring(() => state.applyManifest(manifest('sig-1'))); // Every stage of the plan, decoration included: the build places the trees @@ -84,7 +88,11 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => }); it('counts against a shorter plan when the apply has less to do', async () => { - const state = createCityState(fakeLayoutClient() as never, stubPlacementClient() as never); + const state = createCityState( + fakeLayoutClient() as never, + stubPlacementClient() as never, + WORLD_BUILD_REPORTER + ); await state.applyManifest(manifest('sig-1')); // Same structure signature: the atlas is already right for this tree, so // that stage never runs and must not be promised. @@ -96,7 +104,8 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => it('carries the packer percent while it packs', async () => { const state = createCityState( fakeLayoutClient([7, 61]) as never, - stubPlacementClient() as never + stubPlacementClient() as never, + WORLD_BUILD_REPORTER ); const tails = await tailsDuring(() => state.applyManifest(manifest('sig-1'))); @@ -120,7 +129,11 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => ), dispose: vi.fn(), }; - const state = createCityState(client as never, stubPlacementClient() as never); + const state = createCityState( + client as never, + stubPlacementClient() as never, + WORLD_BUILD_REPORTER + ); void state.applyManifest(manifest('sig-1')); void state.applyManifest(manifest('sig-2')); // supersedes the first diff --git a/app/tests/city/state/applyManifestReuse.test.ts b/app/tests/city/state/applyManifestReuse.test.ts index a07ebd7e..730c6d31 100644 --- a/app/tests/city/state/applyManifestReuse.test.ts +++ b/app/tests/city/state/applyManifestReuse.test.ts @@ -6,6 +6,7 @@ import { stubPlacementClient } from '../../_helpers/cityFixtures'; import { describe, it, expect, vi } from 'vitest'; import { createCityState } from '@/city/state'; +import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; import { NodeKind } from '@/types'; import type { CityLayout, DateRanges, Manifest } from '@/types'; @@ -64,7 +65,11 @@ function fakeLayoutClient() { describe('cityState.applyManifest — reuse gate keys on the layout signature (#74)', () => { it('bumps structureRevision when layout_signature changes (full re-pack)', async () => { - const state = createCityState(fakeLayoutClient() as never, stubPlacementClient() as never); + const state = createCityState( + fakeLayoutClient() as never, + stubPlacementClient() as never, + WORLD_BUILD_REPORTER + ); await state.applyManifest(manifest('L1')); const before = state.structureRevision.value; await state.applyManifest(manifest('L2')); // layout_signature changed @@ -74,7 +79,11 @@ describe('cityState.applyManifest — reuse gate keys on the layout signature (# // The discriminating case: the file size changes but layout_signature does // not, so a gate recomputing it from the tree would re-pack and fail here. it('reuses when layout_signature is unchanged even if the tree size differs', async () => { - const state = createCityState(fakeLayoutClient() as never, stubPlacementClient() as never); + const state = createCityState( + fakeLayoutClient() as never, + stubPlacementClient() as never, + WORLD_BUILD_REPORTER + ); await state.applyManifest(manifest('L1', 10)); const before = state.structureRevision.value; await state.applyManifest(manifest('L1', 999)); // different tree size, SAME field diff --git a/app/tests/hooks/useTimelineMode.test.ts b/app/tests/hooks/useTimelineMode.test.ts index 9855fd8b..35dd1b9a 100644 --- a/app/tests/hooks/useTimelineMode.test.ts +++ b/app/tests/hooks/useTimelineMode.test.ts @@ -14,6 +14,7 @@ import { LOADING_OVERLAY, LOADING_CANCEL, SCAN_PROGRESS, + markIdle, } from '@/state/stores/progress'; import { LoadingStep, TIMELINE_LOADING_STEPS, BuildStage } from '@/constants/progress'; import { LIVE_UPDATES } from '@/state/settings/fields/updates'; @@ -44,8 +45,25 @@ const BUNDLE = { note: null, } as unknown as TimelineBundle; +// The composer marks Idle a frame after the pack returns, and the reveal waits +// for that. Tracked, so an unfired frame can't settle the NEXT test's build. +let pendingSettle = 0; +function settleNextFrame(): void { + cancelPendingSettle(); + pendingSettle = requestAnimationFrame(() => { + pendingSettle = 0; + markIdle(); + }); +} +function cancelPendingSettle(): void { + if (pendingSettle) cancelAnimationFrame(pendingSettle); + pendingSettle = 0; +} + function fakeHandle() { - const applyManifest = vi.fn().mockResolvedValue(undefined); + const applyManifest = vi.fn().mockImplementation(async () => { + settleNextFrame(); + }); // The hook opens the build's readout on the stages the apply will run, so a // handle without this can't be driven through a load. const buildStagesFor = vi.fn().mockReturnValue([BuildStage.Layout, BuildStage.Assemble]); @@ -91,6 +109,7 @@ describe('loadTimelineScene', () => { (fetchTimelineBundle as unknown as ReturnType).mockReset(); }); afterEach(() => { + cancelPendingSettle(); TIMELINE_MODE.value = false; SCENE_HANDLE.value = null; }); @@ -160,6 +179,26 @@ describe('loadTimelineScene', () => { expect(LOADING_OVERLAY.value.visible).toBe(false); }); + it('holds the reveal until the packed city has a frame on screen', async () => { + (fetchTimelineBundle as unknown as ReturnType).mockResolvedValue(BUNDLE); + const f = fakeHandle(); + // The pack resolves a frame before the city is presented. Sample the overlay + // in that gap: lifting there is the bug this waits out. + let visibleBeforeIdle: boolean | null = null; + f.applyManifest.mockImplementation(async () => { + requestAnimationFrame(() => { + visibleBeforeIdle = LOADING_OVERLAY.value.visible; + markIdle(); + }); + }); + SCENE_HANDLE.value = f.handle as never; + + await loadTimelineScene(); + + expect(visibleBeforeIdle, 'still up while the meshes are undrawn').toBe(true); + expect(LOADING_OVERLAY.value.visible).toBe(false); + }); + it('walks the overlay one row per server stage, each carrying its own tail', async () => { let onProgress!: (p: TimelineProgress) => void; let resolveFetch!: (b: TimelineBundle) => void; @@ -378,6 +417,7 @@ describe('loadTimelineScene inPlace refetch', () => { (fetchTimelineBundle as unknown as ReturnType).mockReset(); }); afterEach(() => { + cancelPendingSettle(); TIMELINE_MODE.value = false; SCENE_HANDLE.value = null; }); diff --git a/app/tests/state/stores/progress.test.ts b/app/tests/state/stores/progress.test.ts index c6503f6f..e189e1a7 100644 --- a/app/tests/state/stores/progress.test.ts +++ b/app/tests/state/stores/progress.test.ts @@ -11,31 +11,43 @@ import { setBuildStagePercent, markDecorating, markIdle, + markSceneGone, + BUILT_MANIFEST, + CITY_ON_SCREEN, LOADING_OVERLAY, PENDING_SOURCE_LABEL, + showLoadingOverlay, } from '@/state/stores/progress'; import { SourceKind } from '@/utils/sources'; import { ScanPhase, CloneStage } from '@/api/manifest'; -import { LoadingStep, BuildStage } from '@/constants/progress'; +import { + LoadingStep, + BuildStage, + BUILD_ONLY_STEPS, + TIMELINE_LOADING_STEPS, +} from '@/constants/progress'; +import { EMPTY_MANIFEST } from '../../_helpers/manifestFixtures'; describe('loadingReactions', () => { let dispose: () => void; beforeEach(() => { REBUILD_STATUS.value = RebuildStatus.Idle; + BUILT_MANIFEST.value = null; dispose = attachOverlayDriver(); }); afterEach(() => { dispose(); SCAN_PROGRESS.value = null; REBUILD_STATUS.value = RebuildStatus.Idle; + BUILT_MANIFEST.value = null; BUILD_PROGRESS.value = null; // Visibility is per attach, so one left up is invisible to the next test. LOADING_OVERLAY.value = { visible: false, showOpts: null, activeStep: null, stepTails: {} }; }); - // The scan streams structure, then per-file metadata, then git history. History - // is minutes and only feeds decorations, so the overlay lifts at metadata. + // Structure, then per-file metadata, then git history: three cities, and the + // last is the one with trees. The overlay covers all three or it lies. it('keeps the overlay up while per-file metadata is still pending', () => { SCAN_PROGRESS.value = { @@ -46,39 +58,33 @@ describe('loadingReactions', () => { expect(LOADING_OVERLAY.value.visible).toBe(true); }); - it('reveals the city once metadata has landed, with history still streaming', () => { + it('stays up through the git walk, whose city is the one with trees in it', () => { SCAN_PROGRESS.value = { kind: SourceKind.Remote, phase: ScanPhase.PartialManifest, appliedPending: ['metadata', 'history'], }; expect(LOADING_OVERLAY.value.visible).toBe(true); - // The metadata manifest applies: painting starts, then lands. + // The metadata manifest applies: real heights paint, then land… REBUILD_STATUS.value = RebuildStatus.Rebuilding; SCAN_PROGRESS.value = { kind: SourceKind.Remote, phase: ScanPhase.PartialManifest, appliedPending: ['history'], }; - expect(LOADING_OVERLAY.value.visible).toBe(true); // still painting REBUILD_STATUS.value = RebuildStatus.Idle; - expect(LOADING_OVERLAY.value.visible).toBe(false); - }); + // …and the overlay stays: history is still coming, and it brings the trees. + expect(LOADING_OVERLAY.value.visible, 'history still streaming').toBe(true); + expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.History); - it('does not re-show the overlay while history streams behind the live city', () => { - SCAN_PROGRESS.value = { - kind: SourceKind.Remote, - phase: ScanPhase.PartialManifest, - appliedPending: ['history'], - }; - expect(LOADING_OVERLAY.value.visible).toBe(false); - // The final apply repaints (Rebuilding) — the overlay must not return. + // The final manifest applies, and only its painted frame ends the load. REBUILD_STATUS.value = RebuildStatus.Rebuilding; - SCAN_PROGRESS.value = { - kind: SourceKind.Remote, - phase: ScanPhase.CompleteManifest, - appliedPending: ['history'], - }; + SCAN_PROGRESS.value = { kind: SourceKind.Remote, phase: ScanPhase.CompleteManifest }; + SCAN_PROGRESS.value = null; + expect(LOADING_OVERLAY.value.visible, 'final city still building').toBe(true); + expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.Building); + + REBUILD_STATUS.value = RebuildStatus.Idle; expect(LOADING_OVERLAY.value.visible).toBe(false); }); @@ -109,7 +115,7 @@ describe('loadingReactions', () => { expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.Scanning); }); - it('advances the step to Building on the skeleton phase', () => { + it('advances the step to Sketching layout on the skeleton phase', () => { SCAN_PROGRESS.value = { kind: SourceKind.Remote, phase: ScanPhase.PartialManifest }; expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.Skeleton); }); @@ -211,7 +217,7 @@ describe('loadingReactions', () => { expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.Skeleton); }); - it('moves to Building city when the real heights land', () => { + it('moves to Reading history when the real heights land', () => { SCAN_PROGRESS.value = { kind: SourceKind.Remote, phase: ScanPhase.PartialManifest, @@ -224,7 +230,7 @@ describe('loadingReactions', () => { appliedPending: ['history'], }; - expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.Building); + expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.History); }); it('never walks the list backwards inside one load', () => { @@ -253,13 +259,48 @@ describe('loadingReactions', () => { expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.Cloning); }); - it('does NOT show the overlay for a settings rebuild (no stream)', () => { - // A config Save sets Rebuilding with no SCAN_PROGRESS — the footer owns that - // status, not the loading overlay. + it('does NOT show the overlay for a rebuild under a city already up', () => { + // A config Save sets Rebuilding with no SCAN_PROGRESS. There is a city on + // screen throughout, so the footer owns that status, not the overlay. + BUILT_MANIFEST.value = EMPTY_MANIFEST; REBUILD_STATUS.value = RebuildStatus.Rebuilding; expect(LOADING_OVERLAY.value.visible).toBe(false); }); + // Re-opening the project you were just in never streams, but leaving /city + // threw the canvas away: that rebuild is the whole wait, so it gets the overlay. + + it('shows the overlay for a build with nothing on screen behind it', () => { + REBUILD_STATUS.value = RebuildStatus.Rebuilding; + + expect(LOADING_OVERLAY.value.visible).toBe(true); + expect(LOADING_OVERLAY.value.activeStep).toBe(LoadingStep.Building); + // Nothing was fetched, so the fetch rows would be rows for work nobody did. + expect(LOADING_OVERLAY.value.showOpts?.steps).toEqual(BUILD_ONLY_STEPS); + + REBUILD_STATUS.value = RebuildStatus.Idle; + expect(LOADING_OVERLAY.value.visible).toBe(false); + }); + + it('leaves an overlay someone else raised alone', () => { + // Timeline shows its own list before its build starts; adopting it would + // swap the rows out from under the load driving them. + showLoadingOverlay({ kind: SourceKind.Remote, steps: TIMELINE_LOADING_STEPS }); + REBUILD_STATUS.value = RebuildStatus.Rebuilding; + + expect(LOADING_OVERLAY.value.showOpts?.steps).toEqual(TIMELINE_LOADING_STEPS); + }); + + it('has no city on screen once the canvas goes away', () => { + markIdle(); + BUILT_MANIFEST.value = EMPTY_MANIFEST; // markIdle copies MANIFEST, empty here + expect(CITY_ON_SCREEN.value).toBe(true); + + markSceneGone(); + expect(CITY_ON_SCREEN.value).toBe(false); + expect(REBUILD_STATUS.value).toBe(RebuildStatus.Pending); + }); + // The build's stages go to the overlay's row and nowhere else. They pass in a // few frames, and the freshness readout flickering through them was noise. From a82688a48f303b0bef9aa0862733cbd42ce663b4 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 12:50:07 -0400 Subject: [PATCH 02/37] Say in LOADING.md what now takes the overlay down The doc already owned "when the city is actually on screen"; the overlay's lifetime is the same question one layer up, including the part where the landing's wallpaper is not the world. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/city/render/LOADING.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/city/render/LOADING.md b/app/src/city/render/LOADING.md index f442a025..a76e64fc 100644 --- a/app/src/city/render/LOADING.md +++ b/app/src/city/render/LOADING.md @@ -28,6 +28,30 @@ is invisible; a frame early is the bug. A build that errored will never present anything, so the overlay cannot wait for a frame that is not coming. `Error` ends the wait and shows the failure. +## What the overlay does with it + +`Idle` is the _only_ thing that takes the loading overlay down. Not the stream +ending, not the heights landing: a scan streams three cities (structure, then +per-file metadata, then git history), and the last of them is the one with +trees in it, because commits are what trees are made of. Lifting at metadata +revealed a correct-looking city that then grew trees under you, so the overlay +now waits out the git walk too — the `Reading history` row is that wait. + +The cost is honest: a cold scan of a big repo holds the overlay through minutes +of `git log`. Cancel is right there, and every row says which stage is running. + +A build with no stream behind it raises the overlay by itself. Re-opening the +project you were just in never fetches anything — the manifest is still in hand +— but leaving `/city` unmounted the canvas, so the whole city gets packed again +from scratch. That rebuild is the entire wait, and it used to run behind a blank +stage. `CITY_ON_SCREEN` is what tells the two apart: a rebuild UNDER a city (a +settings Save, a live update) is the footer's to report, not the overlay's. + +The landing's wallpaper builds through this same pipeline and is _not_ the +world, so it reports through a silent `BuildReporter`. Otherwise its `markIdle` +left "a city is on screen" true for a canvas the landing had already thrown +away, and the next open skipped its overlay. + ## Everything downstream gets this for free `captureHarness` waits for `Idle` before taking a screenshot, and used to be From cc376b29970cc7f6612e15f89d58701c8502a0cc Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 12:51:09 -0400 Subject: [PATCH 03/37] Drop the last ProjectsView reference from the loading column That view is gone: the landing navigates and the overlay is the only surface this column is rendered in. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/loading/LoadingProgress/LoadingProgress.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/components/loading/LoadingProgress/LoadingProgress.css b/app/src/components/loading/LoadingProgress/LoadingProgress.css index 91ef2648..37a711c2 100644 --- a/app/src/components/loading/LoadingProgress/LoadingProgress.css +++ b/app/src/components/loading/LoadingProgress/LoadingProgress.css @@ -1,6 +1,6 @@ /* ── Loading progress ────────────────────────────────────────────────────── - Shared column: repo label + spinner + current-step status + stepped list. - Used by LoadingOverlay (cold boot) and ProjectsView (in-app switches). */ + The loading column: repo label + spinner + current-step status + stepped + list. Its shell (backdrop, card) is LoadingOverlay's. */ /* Repo name (+ branch pill) — the header of the loading column. Holds its row whether or not the label has arrived, so nothing below it moves when it does. */ From 9a0454e5528dd2a6c337138498f1b1333d98ef97 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 13:13:53 -0400 Subject: [PATCH 04/37] Make a city self-contained, so two can be mounted and neither hears the other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing's wallpaper and the project you opened are two cities built by one composer, and the composer reached straight into app state: it marked the world Idle, drove the build readouts, refit its camera off CURRENT_SOURCE_KEY, pruned selections off the global scrub, and on a Timeline exit repacked itself from MANIFEST. So the landing's wallpaper was telling the app the world was on screen while its canvas was already gone, and a settings Save on the landing packed the opened project into the wallpaper. Everything a city touches outside itself is now injected. CityBindings carries three things — a BuildReporter, a subject key, and a timeline binding — and a city given none of them reads and writes no app state at all: it builds, frames itself once and disposes in silence. city/bindings.ts is the only file that says which signals mean "the project you opened". The wallpaper is the same city with nothing bound. The settings reactions were the other half: one attach per city, so they take the city's own manifest and its own reporter rather than reading MANIFEST and the world's status directly. twoCities.test.ts mounts both at once and holds the line: the unbound one builds without moving the status, without claiming to be on screen, and a Timeline exit and a source switch pass it by. Each of its four cases goes red if the defaults are re-coupled. One behavior change falls out: a landing visited before any project was ever opened now frames its wallpaper, where the CURRENT_SOURCE_KEY guard used to leave it on the boot pose. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/city/City.tsx | 37 ++-- app/src/city/bindings.ts | 28 +++ app/src/city/index.ts | 60 ++++--- app/src/city/types/index.ts | 28 +++ app/src/state/settings/reactions.ts | 36 ++-- app/src/state/stores/progress.ts | 12 ++ app/tests/city/builtCity.test.ts | 5 +- app/tests/city/index.test.ts | 9 +- app/tests/city/initialFraming.test.ts | 7 +- .../city/interaction/inputHandlers.test.ts | 3 +- app/tests/city/twoCities.test.ts | 163 ++++++++++++++++++ app/tests/city/worldLayoutCache.test.ts | 5 +- app/tests/state/settings/reactions.test.ts | 11 +- 13 files changed, 328 insertions(+), 76 deletions(-) create mode 100644 app/src/city/bindings.ts create mode 100644 app/tests/city/twoCities.test.ts diff --git a/app/src/city/City.tsx b/app/src/city/City.tsx index f3e8b099..22a00209 100644 --- a/app/src/city/City.tsx +++ b/app/src/city/City.tsx @@ -12,13 +12,12 @@ import { attachSettingsReactions } from '@/state/settings/reactions'; import { BACKDROP_HANDLE, SCENE_HANDLE } from '@/city/sceneHandle'; import { MANIFEST } from '@/state/stores/manifest'; import { - markRebuilding, - markError, markSceneGone, WORLD_BUILD_REPORTER, SILENT_BUILD_REPORTER, } from '@/state/stores/progress'; import { TIMELINE_MODE } from '@/state/stores/timeline'; +import { WORLD_BINDINGS, SCENERY_BINDINGS } from '@/city/bindings'; import { reapplyTimelineScene } from '@/hooks/useTimelineMode'; import type { Manifest } from '@/types'; @@ -51,9 +50,7 @@ export function City({ variant = CityVariant.Scene }: CityProps = {}) { const isWorld = variant === CityVariant.Scene; createCity(canvas, { cameraMode: isWorld ? CameraMode.Project : CameraMode.Backdrop, - // Same pipeline, but the wallpaper is not the project you opened: its - // finish must not read as the world being on screen. - report: isWorld ? WORLD_BUILD_REPORTER : SILENT_BUILD_REPORTER, + ...(isWorld ? WORLD_BINDINGS : SCENERY_BINDINGS), }) .then((handle) => { // Unmounted before the async build resolved: dispose the orphan now, or @@ -66,13 +63,23 @@ export function City({ variant = CityVariant.Scene }: CityProps = {}) { // Published to its own slot: the two variants are independent cities. if (variant === CityVariant.Scene) SCENE_HANDLE.value = handle; else BACKDROP_HANDLE.value = handle; + // Each city rebuilds from what IT is showing: the wallpaper is a + // different repo, and the opened project's manifest is not its own. disposeReactions = attachSettingsReactions({ - // Rebuild the current mode (Timeline: union + scrub; Live: HEAD). - rebuildScene: () => - TIMELINE_MODE.peek() - ? reapplyTimelineScene() - : handle.applyManifest(MANIFEST.peek() as Manifest), + rebuildScene: isWorld + ? // Rebuild the current mode (Timeline: union + scrub; Live: HEAD). + () => + TIMELINE_MODE.peek() + ? reapplyTimelineScene() + : handle.applyManifest(MANIFEST.peek() as Manifest) + : () => { + const showing = handle.manifest.peek(); + return showing ? handle.applyManifest(showing) : Promise.resolve(); + }, invalidateLayoutCache: handle.invalidateLayoutCache, + currentManifest: () => + isWorld ? (MANIFEST.peek() as Manifest | null) : handle.manifest.peek(), + report: isWorld ? WORLD_BUILD_REPORTER : SILENT_BUILD_REPORTER, }); // A backdrop shows what its view decided to show, which is not the @@ -87,14 +94,14 @@ export function City({ variant = CityVariant.Scene }: CityProps = {}) { // Live's bridge from manifest to scene; Timeline packs its own union // city. Peeked, so leaving the mode doesn't repack what it committed. if (TIMELINE_MODE.peek()) return; - markRebuilding(); - void handle.applyManifest(m).catch(markError); + WORLD_BUILD_REPORTER.markRebuilding(); + void handle.applyManifest(m).catch(WORLD_BUILD_REPORTER.markError); }); }) .catch((err) => { - // No WebGL, or a context the driver refused. The landing has its - // wallpaper to fall back on; the city route has nothing to show. - if (variant === CityVariant.Scene) markError(err); + // No WebGL, or a context the driver refused. The landing has its hero + // image to fall back on, which is what its silent reporter says. + (isWorld ? WORLD_BUILD_REPORTER : SILENT_BUILD_REPORTER).markError(err); }); return () => { diff --git a/app/src/city/bindings.ts b/app/src/city/bindings.ts new file mode 100644 index 00000000..b17c1430 --- /dev/null +++ b/app/src/city/bindings.ts @@ -0,0 +1,28 @@ +// city/bindings.ts — the seam between a city and the app around it. The city +// layer reads no store; this is the one file that says which signals stand for +// "the project you opened", so a second city can be mounted beside it and +// neither one can hear the other. + +import { MANIFEST } from '@/state/stores/manifest'; +import { CURRENT_SOURCE_KEY } from '@/state/stores/source'; +import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { TIMELINE_MODE, SCRUB_POS, SCRUB_DRAGGING } from '@/state/stores/timeline'; +import type { CityBindings } from './types'; +import type { Manifest } from '@/types'; + +/** The city on `/city`: the one whose build every readout describes, whose + * subject is the opened project, and the only one anything scrubs. */ +export const WORLD_BINDINGS: CityBindings = { + report: WORLD_BUILD_REPORTER, + subjectKey: () => CURRENT_SOURCE_KEY.peek(), + timeline: { + mode: TIMELINE_MODE, + scrubPos: SCRUB_POS, + scrubDragging: SCRUB_DRAGGING, + liveManifest: () => MANIFEST.peek() as Manifest | null, + }, +}; + +/** The landing's wallpaper: nobody waits for it, nobody scrubs it, and it + * paints one repo per visit, so it frames itself once and reports nothing. */ +export const SCENERY_BINDINGS: CityBindings = {}; diff --git a/app/src/city/index.ts b/app/src/city/index.ts index 3adf0dd6..118180f1 100644 --- a/app/src/city/index.ts +++ b/app/src/city/index.ts @@ -6,11 +6,8 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; -import type { Manifest, RangeStat } from '@/types'; -import { CURRENT_SOURCE_KEY } from '@/state/stores/source'; -import { MANIFEST } from '@/state/stores/manifest'; -import { WORLD_BUILD_REPORTER, type BuildReporter } from '@/state/stores/progress'; -import { TIMELINE_MODE, SCRUB_DRAGGING, SCRUB_POS } from '@/state/stores/timeline'; +import type { RangeStat } from '@/types'; +import { SILENT_BUILD_REPORTER } from '@/state/stores/progress'; import { registerShaderChunks } from './utils/shaders/registerShaderChunks'; import { createBuildings } from './components/buildings'; @@ -37,7 +34,7 @@ import { createStreets } from './components/streets'; import { createTrees } from './components/trees'; import { createFireflies } from './components/fireflies'; import { createPathLine } from './components/pathLine'; -import type { City, SceneComponent, SceneContext } from './types'; +import type { City, CityBindings, SceneComponent, SceneContext } from './types'; import { createCameraRig, CameraMode } from './render/cameraRig'; import { createPicker } from './interaction/picker'; import { createInputHandlers } from './interaction/inputHandlers'; @@ -46,12 +43,20 @@ import { createPostFx } from './render/postFx'; import { startFrameLoop } from './render/frameLoop'; import { registerRenderer as registerFacadePanelRenderer } from './components/buildings/facadePanelTextureArray'; +// One city, framed once: the default subject never changes, so an apply that +// only refines the same city (skeleton → heights → history) holds the camera. +const ONE_CITY = 'city'; + export async function createCity( canvas: HTMLCanvasElement, { cameraMode = CameraMode.Project, - report = WORLD_BUILD_REPORTER, - }: { cameraMode?: CameraMode; report?: BuildReporter } = {} + // Inert by default: a city reports nothing, frames itself once, and has no + // timeline until whoever mounts it says otherwise (see CityBindings). + report = SILENT_BUILD_REPORTER, + subjectKey = () => ONE_CITY, + timeline, + }: { cameraMode?: CameraMode } & CityBindings = {} ): Promise { // Must precede any ShaderMaterial so #include directives resolve. registerShaderChunks(); @@ -140,17 +145,17 @@ export async function createCity( getTreeBoundsBySha: (sha) => trees.getRenderer()?.getTreeBoundsBySha(sha) ?? null, }, }); - // Reframe only on a real source change. cityRevision bumps after the apply's + // Reframe only on a real subject change. cityRevision bumps after the apply's // batch flushed, so the camera is aimed at a built city, not half of one. - let lastReframedSourceKey: string | null = null; + let framedSubject: string | null = null; const stopReframe = effect(() => { void cityState.cityRevision.value; - const key = CURRENT_SOURCE_KEY.peek(); - if (key === null || key === lastReframedSourceKey) return; - // No city yet: claiming the key here would make the first real one, which - // is what there is to frame, skip its reframe. + const subject = subjectKey(); + if (subject === null || subject === framedSubject) return; + // No city yet: claiming the subject here would make the first real one, + // which is what there is to frame, skip its reframe. if (cityState.manifest.peek() === null) return; - lastReframedSourceKey = key; + framedSubject = subject; untracked(() => rig.reset()); }); @@ -246,8 +251,8 @@ export async function createCity( after() { // Drop a selection the scrub removed, but not mid-drag: closing the right // sidebar then reflows the track under the pointer and jumps the position. - if (!TIMELINE_MODE.peek() || SCRUB_DRAGGING.peek()) return; - const pos = SCRUB_POS.peek(); + if (!timeline?.mode.peek() || timeline.scrubDragging.peek()) return; + const pos = timeline.scrubPos.peek(); if (pos === _lastPrunedScrubPos) return; _lastPrunedScrubPos = pos; picker.pruneScrubHiddenSelection(); @@ -296,17 +301,20 @@ export async function createCity( }; // Every Timeline exit. The union city holds buildings that do not exist at - // HEAD, so only a rebuild from the live MANIFEST is a valid live city. - const stopTimelineTeardown = effect(() => { - if (TIMELINE_MODE.value || !_scrubController) return; - timelineApi.uninstallScrubController(); - const live = MANIFEST.peek() as Manifest | null; - // Best-effort: a dispose or a newer apply can supersede this mid-flight, - // and neither is a failure worth surfacing from a teardown. - if (live) void applyManifest(live).catch(() => {}); - }); + // HEAD, so only a rebuild from the live manifest is a valid live city. + const stopTimelineTeardown = timeline + ? effect(() => { + if (timeline.mode.value || !_scrubController) return; + timelineApi.uninstallScrubController(); + const live = timeline.liveManifest(); + // Best-effort: a dispose or a newer apply can supersede this mid-flight, + // and neither is a failure worth surfacing from a teardown. + if (live) void applyManifest(live).catch(() => {}); + }) + : () => {}; return { + manifest: cityState.manifest, scene, picker, rig, diff --git a/app/src/city/types/index.ts b/app/src/city/types/index.ts index 921c2665..db38bff6 100644 --- a/app/src/city/types/index.ts +++ b/app/src/city/types/index.ts @@ -7,8 +7,10 @@ import type { CameraRig } from '../render/cameraRig'; import type { CityState } from '../state'; import type { Trees } from '../components/trees/treeRenderer'; import type { PathTimeline } from '../timeline/replay'; +import type { ReadonlySignal } from '@preact/signals'; import type { Manifest, RangeStat } from '@/types'; import type { BuildStage } from '@/constants/progress'; +import type { BuildReporter } from '@/state/stores/progress'; /** What a component needs to wire itself in. picker is null until after the * components exist, so anything needing it arms on the first tick. */ @@ -62,7 +64,33 @@ export interface CityTimeline { } /** The top-level city object returned by the city composer (createCity). */ +/** Timeline scrub, for a city something is scrubbing. Absent from a city's + * bindings, the mode does not exist for it and nothing prunes its selection. */ +export interface CityTimelineBinding { + /** Tracked: leaving the mode tears this city's scrub controller down. */ + mode: ReadonlySignal; + /** Peeked per frame: where the scrubber is, and whether it is still held. */ + scrubPos: ReadonlySignal; + scrubDragging: ReadonlySignal; + /** What to rebuild from on exit: a union city holds buildings that do not + * exist at HEAD. A call, not a signal, so no store's type leaks in here. */ + liveManifest(): Manifest | null; +} + +/** Everything a city is wired to outside itself. Given none of it, a city + * touches no app state: mount two, and neither can hear the other. */ +export interface CityBindings { + /** Where this city's build status goes. Silent unless something is waiting. */ + report?: BuildReporter; + /** What this city is a picture OF, as one comparable string. The camera + * refits when it changes, so skeleton→heights→history frames once. */ + subjectKey?(): string | null; + timeline?: CityTimelineBinding; +} + export interface City { + /** What this city is showing: the manifest its last apply landed. */ + manifest: ReadonlySignal; scene: THREE.Scene; picker: Picker; rig: CameraRig; diff --git a/app/src/state/settings/reactions.ts b/app/src/state/settings/reactions.ts index aa7da714..7f95e2e3 100644 --- a/app/src/state/settings/reactions.ts +++ b/app/src/state/settings/reactions.ts @@ -4,16 +4,9 @@ import { computed, effect, untracked } from '@preact/signals'; -import { MANIFEST } from '@/state/stores/manifest'; -import { - REBUILD_STATUS, - RebuildStatus, - LAST_UPDATED_AT, - markRebuilding, - markIdle, - markError, -} from '@/state/stores/progress'; +import { RebuildStatus, type BuildReporter } from '@/state/stores/progress'; import { routeSignature, ChangeRoute } from '@/state/settings/schema'; +import type { Manifest } from '@/types'; // Min-dwell for the 'rebuilding' indicator on the material-only path. const HOT_REBUILD_MIN_DWELL_MS = 220; @@ -23,6 +16,12 @@ interface SettingsReactionsOpts { * Save keeps the user in the mode they're in. */ rebuildScene(): Promise; invalidateLayoutCache(): void; + /** What the city being rebuilt is showing, or null when there is nothing to + * rebuild. Per city: two of them are two different answers. */ + currentManifest(): Manifest | null; + /** This city's status channel, so a Save to a city nobody is waiting for + * cannot drive the readouts describing the one they are. */ + report: BuildReporter; } // ── Change signatures (auto-generated by route) ──────────────────────────── @@ -33,6 +32,8 @@ const REFRESH_SIGNATURE = computed(() => routeSignature(ChangeRoute.Refresh)); export function attachSettingsReactions({ rebuildScene, invalidateLayoutCache, + currentManifest, + report, }: SettingsReactionsOpts): () => void { // Effects fire synchronously on first call. Suppress reactions until all // subscriptions are wired so the initial fire doesn't trigger a rebuild. @@ -42,22 +43,20 @@ export function attachSettingsReactions({ async function scheduleRebuild() { if (!armed) return; - markRebuilding(); + report.markRebuilding(); try { // Always invalidate: the manifest is unchanged, so reuseLayoutFrom would // skip the recompute and the setting would do nothing visible. invalidateLayoutCache(); - // peek, not .value: a tracked read here double-applies with City's render - // effect. MANIFEST is only the "project loaded?" proxy. - if (!MANIFEST.peek()) { - markIdle(); // no world to rebuild — settle immediately + if (!currentManifest()) { + report.markIdle(); // no world to rebuild — settle immediately return; } await rebuildScene(); // Idle is owned by the trees decoration pass (markIdle), the last stage of // applyManifest — setting it here would stomp its Decorating state. } catch (err) { - markError(err); + report.markError(err); } } @@ -66,15 +65,12 @@ export function attachSettingsReactions({ function flagRefresh() { if (!armed) return; if (hotIdleTimer) clearTimeout(hotIdleTimer); - markRebuilding(); + report.markRebuilding(); // Min-dwell so the flash is visible, and only if no real rebuild is in // flight: applyManifest owns the final status then. hotIdleTimer = setTimeout(() => { hotIdleTimer = 0; - if (REBUILD_STATUS.peek() === RebuildStatus.Rebuilding) { - markIdle(); - LAST_UPDATED_AT.value = Date.now(); - } + if (report.status() === RebuildStatus.Rebuilding) report.markIdle(); }, HOT_REBUILD_MIN_DWELL_MS); } diff --git a/app/src/state/stores/progress.ts b/app/src/state/stores/progress.ts index ec2185c5..d6634547 100644 --- a/app/src/state/stores/progress.ts +++ b/app/src/state/stores/progress.ts @@ -199,8 +199,12 @@ export interface BuildReporter { beginBuild(stages: readonly BuildStage[]): void; enterBuildStage(stage: BuildStage): void; setBuildStagePercent(percent: number): void; + markRebuilding(): void; markDecorating(): void; markIdle(): void; + markError(err: unknown): void; + /** Read back, for a caller checking whether its own flash still stands. */ + status(): RebuildStatus; } /** The project you opened: its build is the wait every readout is about. */ @@ -208,8 +212,11 @@ export const WORLD_BUILD_REPORTER: BuildReporter = { beginBuild, enterBuildStage, setBuildStagePercent, + markRebuilding, markDecorating, markIdle, + markError, + status: () => REBUILD_STATUS.peek(), }; /** Scenery: nobody is waiting for it, and a finished wallpaper claiming the @@ -218,8 +225,13 @@ export const SILENT_BUILD_REPORTER: BuildReporter = { beginBuild: () => {}, enterBuildStage: () => {}, setBuildStagePercent: () => {}, + markRebuilding: () => {}, markDecorating: () => {}, markIdle: () => {}, + // Logged, never surfaced: a city nobody is waiting for has no readout to + // fail in, and a silent throw is how a broken wallpaper goes unnoticed. + markError: (err) => console.error('[codecity] scenery build failed', err), + status: () => RebuildStatus.Pending, }; // ── What the overlay shows ─────────────────────────────────────────── diff --git a/app/tests/city/builtCity.test.ts b/app/tests/city/builtCity.test.ts index 332ae342..f43664ec 100644 --- a/app/tests/city/builtCity.test.ts +++ b/app/tests/city/builtCity.test.ts @@ -35,6 +35,7 @@ vi.mock('@/city/components/buildings/atlas', async () => { }); import { createCity } from '@/city/index'; +import { WORLD_BINDINGS } from '@/city/bindings'; const W = 800; const H = 600; @@ -88,7 +89,7 @@ describe('a built city is pickable', () => { } it('picks the building under the cursor once the build has finished', async () => { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); try { CURRENT_SOURCE.value = { src: 'test://repo' }; await handle.applyManifest(makeManifest()); @@ -120,7 +121,7 @@ describe('a built city is pickable', () => { // The load path end to end: URL → follow → selection → camera. The restore must // not swing overhead, so the pivot→camera offset survives the centring. it('centres a URL selection on the loaded framing, without turning the camera', async () => { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); try { SCENE_HANDLE.value = handle; navigate('/city?src=test%3A%2F%2Frepo&sel=file:src/a.ts', { replace: true }); diff --git a/app/tests/city/index.test.ts b/app/tests/city/index.test.ts index b96019a4..a9e99f59 100644 --- a/app/tests/city/index.test.ts +++ b/app/tests/city/index.test.ts @@ -15,6 +15,7 @@ vi.mock('three', async () => { vi.mock('@/city/render/postFx', async () => (await import('../_helpers/threeMock')).postFxMock()); import { createCity } from '@/city/index'; +import { WORLD_BINDINGS } from '@/city/bindings'; describe('createCity', () => { let rafSpy: ReturnType; @@ -47,7 +48,7 @@ describe('createCity', () => { } it('builds with no manifest and returns the expected handle shape', async () => { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); expect(handle.world).toBeDefined(); expect(handle.picker).toBeDefined(); @@ -55,7 +56,7 @@ describe('createCity', () => { }); it('dispose() releases the WebGL context (forceContextLoss), not just its resources', async () => { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); expect(forceContextLossSpy).not.toHaveBeenCalled(); handle.dispose(); expect(forceContextLossSpy).toHaveBeenCalled(); @@ -67,7 +68,7 @@ describe('createCity', () => { // Only the uninstall is asserted here: the rebuild it triggers needs a // populated manifest to observe, which this harness does not build. it('reacts to TIMELINE_MODE going true→false by uninstalling the controller', async () => { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); handle.timeline.installScrubController(new Map(), []); const uninstallSpy = vi.spyOn(handle.timeline, 'uninstallScrubController'); @@ -81,7 +82,7 @@ describe('createCity', () => { }); it('no-ops when no controller was ever installed', async () => { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); const uninstallSpy = vi.spyOn(handle.timeline, 'uninstallScrubController'); TIMELINE_MODE.value = true; diff --git a/app/tests/city/initialFraming.test.ts b/app/tests/city/initialFraming.test.ts index 596d2c84..f944525a 100644 --- a/app/tests/city/initialFraming.test.ts +++ b/app/tests/city/initialFraming.test.ts @@ -29,6 +29,7 @@ vi.mock('@/city/components/buildings/atlas', async () => { }); import { createCity } from '@/city/index'; +import { WORLD_BINDINGS } from '@/city/bindings'; import type { City } from '@/city/types'; describe('initial-load framing (issue #62)', () => { @@ -89,7 +90,7 @@ describe('initial-load framing (issue #62)', () => { } it('frames the city on initial load, not the empty boot', async () => { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); try { // firstFrame framed the empty boot (no source committed yet → no snap). const bootPos = handle.rig.camera.position.clone(); @@ -116,7 +117,7 @@ describe('initial-load framing (issue #62)', () => { // The route split made this the normal order: the landing commits the // source, THEN the city view mounts a scene onto it. CURRENT_SOURCE.value = { src: 'test://repo' }; - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); try { const bootPos = handle.rig.camera.position.clone(); await build(handle, makeManifest()); @@ -134,7 +135,7 @@ describe('initial-load framing (issue #62)', () => { it('does not reframe on a same-source re-apply (live-update / config save)', async () => { setRootWidth(100); - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); try { CURRENT_SOURCE.value = { src: 'test://repo' }; const m = makeManifest(); diff --git a/app/tests/city/interaction/inputHandlers.test.ts b/app/tests/city/interaction/inputHandlers.test.ts index 0ff1f1d1..079e8b11 100644 --- a/app/tests/city/interaction/inputHandlers.test.ts +++ b/app/tests/city/interaction/inputHandlers.test.ts @@ -20,6 +20,7 @@ vi.mock('@/city/render/postFx', async () => ); import { createCity } from '@/city/index'; +import { WORLD_BINDINGS } from '@/city/bindings'; describe('scene keydown handler — modal suppression', () => { let rafSpy: ReturnType; @@ -51,7 +52,7 @@ describe('scene keydown handler — modal suppression', () => { }); async function mountCity() { - const handle = await createCity(makeCanvas()); + const handle = await createCity(makeCanvas(), WORLD_BINDINGS); cities.push(handle); return handle; } diff --git a/app/tests/city/twoCities.test.ts b/app/tests/city/twoCities.test.ts new file mode 100644 index 00000000..0f094d15 --- /dev/null +++ b/app/tests/city/twoCities.test.ts @@ -0,0 +1,163 @@ +// Two cities, mounted at once, must not be able to hear each other. Everything +// app-wide a city touches is injected (see city/bindings.ts), so the second one +// below is given nothing at all: it builds, frames and disposes in silence +// while the first drives the status, the framing and the scrub the app reads. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EMPTY_MANIFEST } from '../_helpers/manifestFixtures'; +import { mkDir, mkFile } from '../_helpers/cityFixtures'; +import { CURRENT_SOURCE } from '@/state/stores/source'; +import { MANIFEST } from '@/state/stores/manifest'; +import { TIMELINE_MODE } from '@/state/stores/timeline'; +import { + REBUILD_STATUS, + RebuildStatus, + BUILT_MANIFEST, + BUILD_PROGRESS, +} from '@/state/stores/progress'; +import type { Manifest } from '@/types'; + +vi.mock('three', async () => { + const actual = await vi.importActual('three'); + const { fakeWebGLRenderer } = await import('../_helpers/threeMock'); + return { ...actual, WebGLRenderer: fakeWebGLRenderer() }; +}); + +vi.mock('@/city/render/postFx', async () => (await import('../_helpers/threeMock')).postFxMock()); + +// Icon images never fire onload in jsdom and would hang the apply. +vi.mock('@/city/components/buildings/atlas', async () => { + const actual = await vi.importActual( + '@/city/components/buildings/atlas' + ); + return { ...actual, buildIconAtlas: async () => null }; +}); + +import { createCity } from '@/city/index'; +import { WORLD_BINDINGS } from '@/city/bindings'; + +const W = 800; +const H = 600; + +describe('two cities at once', () => { + let rafSpy: ReturnType; + + beforeEach(() => { + CURRENT_SOURCE.value = null; + MANIFEST.value = null; + TIMELINE_MODE.value = false; + REBUILD_STATUS.value = RebuildStatus.Pending; + BUILT_MANIFEST.value = null; + rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + setTimeout(() => cb(performance.now()), 0); + return 0 as unknown as number; + }); + }); + + afterEach(() => { + rafSpy.mockRestore(); + CURRENT_SOURCE.value = null; + MANIFEST.value = null; + TIMELINE_MODE.value = false; + REBUILD_STATUS.value = RebuildStatus.Pending; + BUILT_MANIFEST.value = null; + }); + + function makeCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + Object.defineProperty(canvas, 'clientWidth', { value: W, configurable: true }); + Object.defineProperty(canvas, 'clientHeight', { value: H, configurable: true }); + canvas.getBoundingClientRect = () => + ({ left: 0, top: 0, width: W, height: H, right: W, bottom: H, x: 0, y: 0 }) as DOMRect; + return canvas; + } + + function makeManifest(name: string): Manifest { + return { + ...EMPTY_MANIFEST, + tree: mkDir(name, [mkDir('src', [mkFile('a.ts'), mkFile('b.ts')])]), + structure_signature: `structure-${name}`, + layout_signature: `layout-${name}`, + content_signature: `content-${name}`, + } as unknown as Manifest; + } + + it('builds the unbound one without touching the status the bound one owns', async () => { + const scenery = await createCity(makeCanvas()); + try { + await scenery.applyManifest(makeManifest('wallpaper')); + + expect(scenery.manifest.value, 'it did build').not.toBeNull(); + expect(REBUILD_STATUS.value, 'nobody was told').toBe(RebuildStatus.Pending); + expect(BUILT_MANIFEST.value).toBeNull(); + expect(BUILD_PROGRESS.value).toBeNull(); + } finally { + scenery.dispose(); + } + }); + + it('holds the bound one’s "on screen" while the unbound one builds beside it', async () => { + const world = await createCity(makeCanvas(), WORLD_BINDINGS); + const scenery = await createCity(makeCanvas()); + try { + CURRENT_SOURCE.value = { src: 'test://repo' }; + MANIFEST.value = makeManifest('repo'); + await world.applyManifest(makeManifest('repo')); + await vi.waitFor(() => expect(REBUILD_STATUS.value).toBe(RebuildStatus.Idle)); + + // Sampled DURING the wallpaper's build: a build opens its readout before + // it yields, so a coupled one is visible here and nowhere later. + const scenic = scenery.applyManifest(makeManifest('wallpaper')); + expect(REBUILD_STATUS.value, 'the world is still what is on screen').toBe(RebuildStatus.Idle); + expect(BUILD_PROGRESS.value, 'and nothing claims to be building').toBeNull(); + await scenic; + + expect(scenery.manifest.value?.tree?.name).toBe('wallpaper'); + expect(world.manifest.value?.tree?.name).toBe('repo'); + } finally { + scenery.dispose(); + world.dispose(); + } + }); + + it('leaves the unbound one alone when Timeline mode exits', async () => { + const scenery = await createCity(makeCanvas()); + try { + await scenery.applyManifest(makeManifest('wallpaper')); + scenery.timeline.installScrubController(new Map(), []); + const uninstall = vi.spyOn(scenery.timeline, 'uninstallScrubController'); + // The live manifest a Timeline exit would rebuild from: a DIFFERENT repo, + // which is what used to land on the landing's wallpaper. + MANIFEST.value = makeManifest('repo'); + + TIMELINE_MODE.value = true; + TIMELINE_MODE.value = false; + + expect(uninstall).not.toHaveBeenCalled(); + expect(scenery.manifest.value?.tree?.name).toBe('wallpaper'); + } finally { + scenery.dispose(); + } + }); + + it('holds the unbound one’s camera when the opened project changes', async () => { + const scenery = await createCity(makeCanvas()); + try { + CURRENT_SOURCE.value = { src: 'test://one' }; + await scenery.applyManifest(makeManifest('wallpaper')); + // Spied after the first apply: framing the city it just got IS its job. + const refit = vi.spyOn(scenery.rig, 'reset'); + + // A source switch refits the city that IS that source. This one is a + // picture of something else, and its camera is not the app's to move. + CURRENT_SOURCE.value = { src: 'test://two' }; + await scenery.applyManifest(makeManifest('wallpaper')); + + expect(refit).not.toHaveBeenCalled(); + } finally { + scenery.dispose(); + } + }); +}); diff --git a/app/tests/city/worldLayoutCache.test.ts b/app/tests/city/worldLayoutCache.test.ts index b6d2b665..5a02770f 100644 --- a/app/tests/city/worldLayoutCache.test.ts +++ b/app/tests/city/worldLayoutCache.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { attachSettingsReactions } from '@/state/settings/reactions'; import { setManifest } from '@/state/stores/manifest'; +import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; import { STREET_LAYOUT } from '@/state/settings/fields/streets'; import type { Manifest } from '@/types'; @@ -34,7 +35,7 @@ describe('attachSettingsReactions invalidates layout cache before applyManifest' tree: { type: 'directory', children: [] }, dateRanges: { minCreated: null, maxCreated: null, minModified: null, maxModified: null }, }; - // Seed the source of truth: scheduleRebuild reads MANIFEST.peek(). + // Seed the source of truth: scheduleRebuild skips a city showing nothing. setManifest(stubManifest as unknown as Manifest); detach = attachSettingsReactions({ @@ -44,6 +45,8 @@ describe('attachSettingsReactions invalidates layout cache before applyManifest' invalidateLayoutCache() { calls.push('invalidateLayoutCache'); }, + currentManifest: () => stubManifest as unknown as Manifest, + report: WORLD_BUILD_REPORTER, }); // What Save does: commit() fires setKey on the real store, which is what diff --git a/app/tests/state/settings/reactions.test.ts b/app/tests/state/settings/reactions.test.ts index 8b352a32..ed8c008c 100644 --- a/app/tests/state/settings/reactions.test.ts +++ b/app/tests/state/settings/reactions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { attachSettingsReactions } from '@/state/settings/reactions'; import { setManifest } from '@/state/stores/manifest'; -import { REBUILD_STATUS, RebuildStatus } from '@/state/stores/progress'; +import { REBUILD_STATUS, RebuildStatus, WORLD_BUILD_REPORTER } from '@/state/stores/progress'; import { TREES } from '@/state/settings/fields/trees'; import { GEM } from '@/state/settings/fields/gem'; import type { Manifest } from '@/types'; @@ -16,14 +16,17 @@ describe('attachSettingsReactions routing', () => { beforeEach(() => { rebuildCalls = 0; REBUILD_STATUS.value = RebuildStatus.Idle; - // scheduleRebuild gates on MANIFEST (peek) as the "project loaded?" proxy; - // seed a non-empty manifest so the rebuild path actually calls rebuildScene. - setManifest({ tree: {} } as unknown as Manifest); + // scheduleRebuild gates on the city's own manifest; seed one so the rebuild + // path actually calls rebuildScene. + const manifest = { tree: {} } as unknown as Manifest; + setManifest(manifest); detach = attachSettingsReactions({ rebuildScene: async () => { rebuildCalls++; }, invalidateLayoutCache: () => {}, + currentManifest: () => manifest, + report: WORLD_BUILD_REPORTER, }); }); From 3c83be5266335aecc5273a02ca5177317c227a16 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 13:14:02 -0400 Subject: [PATCH 05/37] Ask the server again every time you open a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-opening the project you were just in loaded nothing: the committed source matched the URL, so the route effect returned and the city was repacked from the manifest already in memory. Come back an hour and a few commits later and you were looking at the old repo, with nothing to say so. Nothing on this machine can know whether a scan still holds — the server decides that, off its own cache, per ask. So leaving the city route arms the next open as a fresh one; the guard now only covers what it was written for, the branch a commit puts in the URL while you stay put. The manifest in hand is dropped when there is no city on screen, so a re-open packs the scan it just asked for instead of building the stale one first and the fresh one a moment later. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/hooks/useManifestSource.ts | 31 ++++++++++++++++------- app/tests/hooks/useManifestSource.test.ts | 18 +++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/src/hooks/useManifestSource.ts b/app/src/hooks/useManifestSource.ts index 778de769..d03bdced 100644 --- a/app/src/hooks/useManifestSource.ts +++ b/app/src/hooks/useManifestSource.ts @@ -31,6 +31,7 @@ import { markRebuilding, SCAN_PROGRESS, PENDING_SOURCE_LABEL, + CITY_ON_SCREEN, } from '@/state/stores/progress'; import { TIMELINE_MODE, resetTimelineMode } from '@/state/stores/timeline'; import { @@ -162,12 +163,11 @@ export async function loadSource(payload: SourcePayload): Promise { // What a cancel rolls back to, captured before the clear below: otherwise the // canceled repo's geometry lingers under the unchanged header. const prevManifest = MANIFEST.peek(); - // A DIFFERENT project retires the one on screen: the route changes on the - // click now, so the last city would draw under the overlay for the load. + // A DIFFERENT project retires the one on screen, and so does a re-open with + // none up: the manifest in hand builds a city this load is about to replace. const opened = CURRENT_SOURCE.peek(); - if (!opened || !sameSourceIdentity(opened, { src: payload.src, branch })) { - setManifest(null); - } + const sameProject = !!opened && sameSourceIdentity(opened, { src: payload.src, branch }); + if (!sameProject || !CITY_ON_SCREEN.peek()) setManifest(null); try { const url = manifestUrlFor({ @@ -390,17 +390,30 @@ const URL_SOURCE = computed(() => { /** Load whatever project the URL names, whenever that changes: the boot read * and every Back/Forward between cities are the same event. Returns a dispose. */ export function attachRouteLoad(): () => void { + // Whether the last thing this saw was a page with no city on it: opening one + // from there is a fresh ask, and only the server knows if its scan holds. + let away = true; return effect(() => { - if (!URL_SOURCE.value) return; + if (!URL_SOURCE.value) { + away = true; + return; + } + const reopened = away; + away = false; // Peeked: the identity above is the trigger, and re-reading the params here // must not subscribe this to the view ones alongside it. const boot = readUrlView(ROUTE_PARAMS.peek()); // Out of the tracking scope: the load writes signals this effect reads. queueMicrotask(() => { - // Committing a remote keeps the branch the server resolved, which lands in - // the URL and moves the identity above. The city on screen is not a new ask. + // Never left, same project: committing a remote puts the branch the + // server resolved in the URL, which moves the identity above by itself. const cur = CURRENT_SOURCE.peek(); - if (cur && boot.src && sameSourceIdentity(cur, { src: boot.src, branch: boot.branch })) { + if ( + !reopened && + cur && + boot.src && + sameSourceIdentity(cur, { src: boot.src, branch: boot.branch }) + ) { return; } void bootLoad(boot); diff --git a/app/tests/hooks/useManifestSource.test.ts b/app/tests/hooks/useManifestSource.test.ts index 96e2c25c..f5eae269 100644 --- a/app/tests/hooks/useManifestSource.test.ts +++ b/app/tests/hooks/useManifestSource.test.ts @@ -510,6 +510,24 @@ describe('the URL drives what is loaded', () => { expect(StubEventSource.instances).toHaveLength(1); }); + // Nothing on this machine can know whether a scan from an hour ago still + // holds. The server decides that, per open, off its own cache. + it('re-asks the server for the project you just left and came back to', async () => { + navigate('/city?src=%2Frepos%2Fa', { replace: true }); + detach = attachRouteLoad(); + await flush(); + await complete('a'); + expect(StubEventSource.instances).toHaveLength(1); + + navigate(ROUTES.HOME); + await flush(); + navigate('/city?src=%2Frepos%2Fa'); + await flush(); + + expect(StubEventSource.instances, 'the same project, asked for again').toHaveLength(2); + expect(srcOf(1)).toBe('/repos/a'); + }); + it('a canceled load leaves its URL loadable', async () => { // The claim stops a re-run restarting an in-flight load. Held past one that // never committed, it leaves the URL naming a repo the city never loads. From 47788c5d1d3ca775d1ba125b4054f957189e037c Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 14:25:37 -0400 Subject: [PATCH 06/37] Every city is a whole city: configure the instance, don't branch on a role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isWorld` was the wrong idea and it spread. The component branched on a variant to pick a camera, a reporter, a handle slot, a rebuild strategy, a manifest to read and whether to clear state on unmount, and two of those branches were unreadable ternaries inside an options object. There is no role now. A city takes one config and there is one code path: Leave a field out and that part is off, which is what makes two of them independent. Most of the branches did not need a config value at all, because the city already knew the answer: - rebuildScene / currentManifest: gone. A city re-packs what IT is showing (`handle.repack`, `handle.manifest`), so the settings reactions take the city and ask it, instead of being handed a manifest and a strategy per variant. The union city under a scrubber reassembles instead, which is the timeline binding's business, not the caller's. - markSceneGone: gone, it is `report.markGone()` — the same channel as every other status write. - BACKDROP_HANDLE: gone. The landing feeds its city a `source` signal and reads its own reporter for "it painted", so it needs no global slot. - The canvas id: gone. Two mounted cities would both have been #city. whenCityOnScreen watched the global status through a promise wrapping an effect wrapping a microtask. It is `handle.whenOnScreen()` now: the composer already knows which revision's frame has been presented, since WebGL has no callback for it, so it hands out that promise directly. isOpenedSource collapses three hand-rolled CURRENT_SOURCE comparisons, and the route-load flag says what it tracks (askedThisVisit) instead of naming a mood (away). Co-Authored-By: Claude Opus 5 (1M context) --- app/src/city/City.css | 2 +- app/src/city/City.tsx | 143 +++++++----------- app/src/city/bindings.ts | 28 ---- app/src/city/index.ts | 39 ++++- app/src/city/openedProject.ts | 34 +++++ app/src/city/sceneHandle.ts | 7 +- app/src/city/types/index.ts | 27 +++- app/src/hooks/useHomeBackdrop.ts | 66 +++++--- app/src/hooks/useManifestSource.ts | 44 ++---- app/src/hooks/useTimelineMode.ts | 3 +- app/src/state/settings/reactions.ts | 31 ++-- app/src/state/stores/progress.ts | 29 +--- app/src/state/stores/source.ts | 12 +- .../CityView/chrome/CityStage/CityStage.tsx | 5 +- app/src/views/HomeView/HomeView.tsx | 12 +- app/tests/_helpers/cityFixtures.ts | 4 +- app/tests/city/applyManifestScenic.test.ts | 4 +- app/tests/city/builtCity.test.ts | 6 +- app/tests/city/components/trees/index.test.ts | 4 +- app/tests/city/index.test.ts | 10 +- app/tests/city/initialFraming.test.ts | 8 +- .../city/interaction/inputHandlers.test.ts | 4 +- .../city/state/applyManifestProgress.test.ts | 10 +- .../city/state/applyManifestReuse.test.ts | 6 +- app/tests/city/twoCities.test.ts | 4 +- app/tests/city/worldLayoutCache.test.ts | 19 ++- app/tests/hooks/useTimelineMode.test.ts | 52 +++---- app/tests/state/settings/reactions.test.ts | 15 +- app/tests/state/stores/progress.test.ts | 4 +- app/tests/views/HomeView/HomeView.test.tsx | 5 +- 30 files changed, 325 insertions(+), 312 deletions(-) delete mode 100644 app/src/city/bindings.ts create mode 100644 app/src/city/openedProject.ts diff --git a/app/src/city/City.css b/app/src/city/City.css index 8a7300e6..cdec6326 100644 --- a/app/src/city/City.css +++ b/app/src/city/City.css @@ -7,6 +7,6 @@ } /* Opaque so a sub-frame gap during resize blends in instead of flashing. */ -.city-canvas--scene { +.city-canvas--opaque { background: var(--cc-bg-app); } diff --git a/app/src/city/City.tsx b/app/src/city/City.tsx index 22a00209..2620c00e 100644 --- a/app/src/city/City.tsx +++ b/app/src/city/City.tsx @@ -1,124 +1,90 @@ -// city/City.tsx — the and its Three.js lifecycle: this folder's mount -// point, beside the createCity API it drives. Mount applies MANIFEST on every -// change, unmount tears the scene down so a remount cannot stack a second -// renderer on it, and the variant is all a view says about what it is FOR. +// city/City.tsx — a city on a , and its Three.js lifecycle. Every city +// is a whole city; two of them differ only in the configuration passed here +// (see CityBindings), so there is one code path and no main one. Unmount tears +// the scene down, or a remount stacks a second renderer on the same canvas. import './City.css'; import { useRef, useEffect } from 'preact/hooks'; -import { effect } from '@preact/signals'; +import { effect, type ReadonlySignal, type Signal } from '@preact/signals'; import { createCity } from '@/city'; -import { CameraMode } from '@/city/render/cameraRig'; import { attachSettingsReactions } from '@/state/settings/reactions'; -import { BACKDROP_HANDLE, SCENE_HANDLE } from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { - markSceneGone, - WORLD_BUILD_REPORTER, - SILENT_BUILD_REPORTER, -} from '@/state/stores/progress'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; -import { WORLD_BINDINGS, SCENERY_BINDINGS } from '@/city/bindings'; -import { reapplyTimelineScene } from '@/hooks/useTimelineMode'; +import { SILENT_BUILD_REPORTER } from '@/state/stores/progress'; +import type { City as CityHandle, CityBindings } from '@/city/types'; import type { Manifest } from '@/types'; -export enum CityVariant { - /** The app's main view: opaque, so a sub-frame gap during resize blends into - * the page instead of flashing through. */ - Scene = 'scene', - /** Wallpaper: transparent, so whatever the view puts behind it (the hero - * image) shows until the city has something to paint. */ - Backdrop = 'backdrop', +export interface CityProps extends CityBindings { + /** What this city shows. Applied on every change, so whoever owns the signal + * owns the city's contents; null leaves the canvas empty. */ + source: ReadonlySignal; + /** Published here while mounted, for whatever drives this instance. */ + handle?: Signal; + /** Paint the canvas: a sub-frame gap during a resize flashes through an + * unpainted one, which only matters where nothing sits behind it. */ + opaque?: boolean; + /** What a screen reader is told this canvas is. */ + label?: string; } -export interface CityProps { - variant?: CityVariant; -} +const DEFAULT_LABEL = + '3D city map of the repository. Files are buildings, directories are streets, commits are trees. Browse it with the file tree and search panels.'; -export function City({ variant = CityVariant.Scene }: CityProps = {}) { +export function City({ + source, + handle, + opaque = false, + label = DEFAULT_LABEL, + report = SILENT_BUILD_REPORTER, + ...bindings +}: CityProps) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; let disposed = false; - let city: Awaited> | null = null; + let city: CityHandle | null = null; let unsubApply: (() => void) | null = null; let disposeReactions: (() => void) | null = null; - // Start empty; the apply-effect below paints the first manifest. The variant - // is also what the camera is FOR, so the rig opens on the right pose itself. - const isWorld = variant === CityVariant.Scene; - createCity(canvas, { - cameraMode: isWorld ? CameraMode.Project : CameraMode.Backdrop, - ...(isWorld ? WORLD_BINDINGS : SCENERY_BINDINGS), - }) - .then((handle) => { + // Starts empty; the apply below paints the first manifest. + createCity(canvas, { report, ...bindings }) + .then((made) => { // Unmounted before the async build resolved: dispose the orphan now, or // its renderer + frame loop leak forever (nothing else holds a ref). if (disposed) { - handle.dispose(); + made.dispose(); return; } - city = handle; - // Published to its own slot: the two variants are independent cities. - if (variant === CityVariant.Scene) SCENE_HANDLE.value = handle; - else BACKDROP_HANDLE.value = handle; - // Each city rebuilds from what IT is showing: the wallpaper is a - // different repo, and the opened project's manifest is not its own. - disposeReactions = attachSettingsReactions({ - rebuildScene: isWorld - ? // Rebuild the current mode (Timeline: union + scrub; Live: HEAD). - () => - TIMELINE_MODE.peek() - ? reapplyTimelineScene() - : handle.applyManifest(MANIFEST.peek() as Manifest) - : () => { - const showing = handle.manifest.peek(); - return showing ? handle.applyManifest(showing) : Promise.resolve(); - }, - invalidateLayoutCache: handle.invalidateLayoutCache, - currentManifest: () => - isWorld ? (MANIFEST.peek() as Manifest | null) : handle.manifest.peek(), - report: isWorld ? WORLD_BUILD_REPORTER : SILENT_BUILD_REPORTER, - }); - - // A backdrop shows what its view decided to show, which is not the - // opened project: useHomeBackdrop drives that canvas itself. - if (variant !== CityVariant.Scene) return; + city = made; + if (handle) handle.value = made; + // The city knows what it is showing and how to re-pack it, so a Save + // needs to be told neither. + disposeReactions = attachSettingsReactions({ city: made, report }); - // Only kicks off the apply and surfaces its error: reaching Idle belongs - // to the decoration pass, and reframing to the city composer. + // Only kicks the apply off and surfaces its error: reaching Idle is the + // decoration pass's, and framing the composer's. unsubApply = effect(() => { - const m = MANIFEST.value as Manifest; - if (!m) return; // nothing to build yet - // Live's bridge from manifest to scene; Timeline packs its own union - // city. Peeked, so leaving the mode doesn't repack what it committed. - if (TIMELINE_MODE.peek()) return; - WORLD_BUILD_REPORTER.markRebuilding(); - void handle.applyManifest(m).catch(WORLD_BUILD_REPORTER.markError); + const m = source.value; + if (!m) return; // nothing to show yet + // Scrubbing owns the contents while it runs. Peeked, so leaving the + // mode doesn't repack what it committed (the teardown owns that). + if (bindings.timeline?.mode.peek()) return; + report.markRebuilding(); + void made.applyManifest(m).catch(report.markError); }); }) - .catch((err) => { - // No WebGL, or a context the driver refused. The landing has its hero - // image to fall back on, which is what its silent reporter says. - (isWorld ? WORLD_BUILD_REPORTER : SILENT_BUILD_REPORTER).markError(err); - }); + .catch(report.markError); // no WebGL, or a context the driver refused return () => { disposed = true; unsubApply?.(); disposeReactions?.(); - // Tear the city down so a remount doesn't stack a second renderer + - // frame loop on the same canvas (old city keeps rendering as a ghost). city?.dispose(); city = null; - // Only its own slot: clearing the other would strand the city still up. - if (variant === CityVariant.Scene) { - SCENE_HANDLE.value = null; - // Nothing is on screen any more, so the remount's rebuild is a load - // with a world to wait for, not a repaint under one. - markSceneGone(); - } else BACKDROP_HANDLE.value = null; + if (handle) handle.value = null; + // The canvas is gone, so nothing it had on it is on screen: a remount + // rebuilds from scratch, and that build is a load with a world to wait for. + report.markGone(); }; }, []); @@ -126,11 +92,10 @@ export function City({ variant = CityVariant.Scene }: CityProps = {}) { // browse the same data through Explore and Search. return ( ); } diff --git a/app/src/city/bindings.ts b/app/src/city/bindings.ts deleted file mode 100644 index b17c1430..00000000 --- a/app/src/city/bindings.ts +++ /dev/null @@ -1,28 +0,0 @@ -// city/bindings.ts — the seam between a city and the app around it. The city -// layer reads no store; this is the one file that says which signals stand for -// "the project you opened", so a second city can be mounted beside it and -// neither one can hear the other. - -import { MANIFEST } from '@/state/stores/manifest'; -import { CURRENT_SOURCE_KEY } from '@/state/stores/source'; -import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; -import { TIMELINE_MODE, SCRUB_POS, SCRUB_DRAGGING } from '@/state/stores/timeline'; -import type { CityBindings } from './types'; -import type { Manifest } from '@/types'; - -/** The city on `/city`: the one whose build every readout describes, whose - * subject is the opened project, and the only one anything scrubs. */ -export const WORLD_BINDINGS: CityBindings = { - report: WORLD_BUILD_REPORTER, - subjectKey: () => CURRENT_SOURCE_KEY.peek(), - timeline: { - mode: TIMELINE_MODE, - scrubPos: SCRUB_POS, - scrubDragging: SCRUB_DRAGGING, - liveManifest: () => MANIFEST.peek() as Manifest | null, - }, -}; - -/** The landing's wallpaper: nobody waits for it, nobody scrubs it, and it - * paints one repo per visit, so it frames itself once and reports nothing. */ -export const SCENERY_BINDINGS: CityBindings = {}; diff --git a/app/src/city/index.ts b/app/src/city/index.ts index 118180f1..2e9897c3 100644 --- a/app/src/city/index.ts +++ b/app/src/city/index.ts @@ -160,19 +160,37 @@ export async function createCity( }); // The build is over when the city is ON SCREEN, not when applyStructure - // returns: it starts the rebuilds without holding them (see render/LOADING.md). + // returns: it starts the rebuilds without holding them (render/LOADING.md). + let presentedRevision = -1; + let awaitingFrame: (() => void)[] = []; const stopOnScreen = effect(() => { - void cityState.cityRevision.value; + const revision = cityState.cityRevision.value; if (cityState.manifest.peek() === null) return; untracked(() => { void buildings.whenSettled().then(() => { // Two frames: render() only issues the GL commands, and the pixels land // a compositor pass later. - requestAnimationFrame(() => requestAnimationFrame(() => report.markIdle())); + requestAnimationFrame(() => + requestAnimationFrame(() => { + // max, not assign: a newer build's frame may already have landed. + presentedRevision = Math.max(presentedRevision, revision); + report.markIdle(); + const waiting = awaitingFrame; + awaitingFrame = []; + for (const resolve of waiting) resolve(); + }) + ); }); }); }); + /** Resolves once a frame carrying the latest build has been presented, or + * immediately when one already has. WebGL offers no such callback. */ + function whenOnScreen(): Promise { + if (presentedRevision === cityState.cityRevision.peek()) return Promise.resolve(); + return new Promise((resolve) => awaitingFrame.push(resolve)); + } + const postFx = createPostFx(renderer, scene, rig.camera); postFx.setSize(canvas.clientWidth, canvas.clientHeight); @@ -313,8 +331,18 @@ export async function createCity( }) : () => {}; + /** Re-pack what is already on screen: the settings path. A union city under + * a scrubber was not built from one manifest, so it reassembles instead. */ + async function repack(): Promise { + if (timeline?.mode.peek()) return timeline.repack(); + const showing = cityState.manifest.peek(); + if (showing) await applyManifest(showing); + } + return { manifest: cityState.manifest, + whenOnScreen, + repack, scene, picker, rig, @@ -334,6 +362,11 @@ export async function createCity( /** Full teardown, loop FIRST, renderer LAST — else a remount stacks a * ghost city whose picker still answers raycasts. */ dispose(): void { + // Nothing more will be presented, so anything waiting for a frame is + // waiting for one that is not coming. + const waiting = awaitingFrame; + awaitingFrame = []; + for (const resolve of waiting) resolve(); stopFrameLoop(); stopReframe(); stopOnScreen(); diff --git a/app/src/city/openedProject.ts b/app/src/city/openedProject.ts new file mode 100644 index 00000000..601c03d4 --- /dev/null +++ b/app/src/city/openedProject.ts @@ -0,0 +1,34 @@ +// city/openedProject.ts — the configuration that makes a city BE the project +// you opened: what it shows, whose readouts it drives, what it is a picture +// of, and the scrubber that owns it in Timeline. The city layer reads no +// store, so this is the one file naming those signals. + +import { computed } from '@preact/signals'; +import { MANIFEST } from '@/state/stores/manifest'; +import { CURRENT_SOURCE_KEY } from '@/state/stores/source'; +import { OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; +import { TIMELINE_MODE, SCRUB_POS, SCRUB_DRAGGING } from '@/state/stores/timeline'; +import { SCENE_HANDLE } from '@/city/sceneHandle'; +import { reapplyTimelineScene } from '@/hooks/useTimelineMode'; +import { CameraMode } from '@/city/render/cameraRig'; +import type { CityProps } from '@/city/City'; +import type { Manifest } from '@/types'; + +/** MANIFEST, as the city layer takes it: the store's value spans the skeleton + * the stream emits before it is fully typed. */ +const OPENED_MANIFEST = computed(() => MANIFEST.value as Manifest | null); + +export const OPENED_PROJECT: CityProps = { + source: OPENED_MANIFEST, + cameraMode: CameraMode.Project, + report: OPENED_PROJECT_REPORTER, + subjectKey: () => CURRENT_SOURCE_KEY.peek(), + timeline: { + mode: TIMELINE_MODE, + scrubPos: SCRUB_POS, + scrubDragging: SCRUB_DRAGGING, + liveManifest: () => MANIFEST.peek() as Manifest | null, + repack: reapplyTimelineScene, + }, + handle: SCENE_HANDLE, +}; diff --git a/app/src/city/sceneHandle.ts b/app/src/city/sceneHandle.ts index f48c2f48..1c154e56 100644 --- a/app/src/city/sceneHandle.ts +++ b/app/src/city/sceneHandle.ts @@ -10,13 +10,10 @@ import { IS_PHONE } from '@/state/stores/viewport'; export type SceneHandle = Awaited>; -/** The city on the /city route. Only the Scene variant publishes here. */ +/** The city the app's chrome drives: sidebars, selection chip, capture. One + * published nowhere (the landing's wallpaper) is nobody's to command. */ export const SCENE_HANDLE = signal(null); -/** The landing's wallpaper city: a different city on a different canvas, so - * sharing a slot made whichever mounted last the other's applyManifest target. */ -export const BACKDROP_HANDLE = signal(null); - /** Resolves once the city exists. A boot load can outrun createCity, and a load * that finds no handle has nowhere to put its city. */ export function whenSceneHandle(): Promise { diff --git a/app/src/city/types/index.ts b/app/src/city/types/index.ts index db38bff6..ede8ee2e 100644 --- a/app/src/city/types/index.ts +++ b/app/src/city/types/index.ts @@ -3,7 +3,7 @@ import type * as THREE from 'three'; import type { Picker } from '../interaction/picker'; -import type { CameraRig } from '../render/cameraRig'; +import type { CameraMode, CameraRig } from '../render/cameraRig'; import type { CityState } from '../state'; import type { Trees } from '../components/trees/treeRenderer'; import type { PathTimeline } from '../timeline/replay'; @@ -64,8 +64,8 @@ export interface CityTimeline { } /** The top-level city object returned by the city composer (createCity). */ -/** Timeline scrub, for a city something is scrubbing. Absent from a city's - * bindings, the mode does not exist for it and nothing prunes its selection. */ +/** Scrubbing, for a city something scrubs through time. Left out, this city + * has no timeline: nothing gates its contents and nothing prunes its picks. */ export interface CityTimelineBinding { /** Tracked: leaving the mode tears this city's scrub controller down. */ mode: ReadonlySignal; @@ -75,15 +75,20 @@ export interface CityTimelineBinding { /** What to rebuild from on exit: a union city holds buildings that do not * exist at HEAD. A call, not a signal, so no store's type leaks in here. */ liveManifest(): Manifest | null; + /** Re-pack while the mode is on, for a setting that moved the packer's + * inputs: the union city is assembled, not applied from one manifest. */ + repack(): Promise; } -/** Everything a city is wired to outside itself. Given none of it, a city - * touches no app state: mount two, and neither can hear the other. */ +/** How a city is configured: what the one mounting it decides. Leave a field + * out and that part is off, which is what makes two of them independent. */ export interface CityBindings { - /** Where this city's build status goes. Silent unless something is waiting. */ + /** Camera behaviour: what this city is for. */ + cameraMode?: CameraMode; + /** Where its build status goes. Left out, it reports to nobody. */ report?: BuildReporter; - /** What this city is a picture OF, as one comparable string. The camera - * refits when it changes, so skeleton→heights→history frames once. */ + /** What it is a picture OF, as one comparable string. The camera refits when + * it changes, so skeleton→heights→history frames once, not three times. */ subjectKey?(): string | null; timeline?: CityTimelineBinding; } @@ -98,6 +103,12 @@ export interface City { /** The stages that apply would run, for a caller whose own work comes first. */ buildStagesFor(m: Manifest): BuildStage[]; invalidateLayoutCache(): void; + /** Re-pack what this city is already showing: the settings path, where the + * manifest has not moved and only the packer's inputs have. */ + repack(): Promise; + /** Resolves once a frame carrying the latest build has been presented. There + * is no such callback in WebGL, so the composer keeps this one. */ + whenOnScreen(): Promise; world: CityWorld; /** Timeline-mode install surface (see hooks/useTimelineMode). The controller * is built here because it needs the components' mesh/attr resolvers. */ diff --git a/app/src/hooks/useHomeBackdrop.ts b/app/src/hooks/useHomeBackdrop.ts index 46330452..1f5469ca 100644 --- a/app/src/hooks/useHomeBackdrop.ts +++ b/app/src/hooks/useHomeBackdrop.ts @@ -3,16 +3,22 @@ // had cached for it, then the featured repo. Neither scans, and every failure // is silent, since the hero image underneath is already a complete answer. -// Applies the manifest STRAIGHT TO THE SCENE, never writing MANIFEST: that -// signal means "the project you opened", which a backdrop is not. +// Its own source signal and its own reporter, never MANIFEST and never the +// app's status: those mean "the project you opened", which a backdrop is not. -import { useEffect } from 'preact/hooks'; -import { effect } from '@preact/signals'; +import { useEffect, useMemo } from 'preact/hooks'; +import { effect, signal, type ReadonlySignal } from '@preact/signals'; import { fetchCachedManifest, manifestUrlFor, streamManifest, ScanPhase } from '@/api/manifest'; import { SERVER_CONFIG } from '@/state/stores/serverData'; -import { BACKDROP_HANDLE, type SceneHandle } from '@/city/sceneHandle'; +import { SILENT_BUILD_REPORTER, type BuildReporter } from '@/state/stores/progress'; import { MANIFEST } from '@/state/stores/manifest'; -import { RECENTS, CURRENT_SOURCE, BACKDROP_CITY, BackdropKind } from '@/state/stores/source'; +import { + RECENTS, + CURRENT_SOURCE, + BACKDROP_CITY, + BackdropKind, + type BackdropCity, +} from '@/state/stores/source'; import { identityBranch, resolveBranch, sameSourceIdentity } from '@/utils/sources'; import type { Manifest } from '@/types'; @@ -83,7 +89,34 @@ function candidates(featuredRepo: string | undefined): Candidate[] { return out; } -export function useHomeBackdrop(): void { +/** What the landing hands its city: the repo it picked, and a status channel + * nobody else reads. */ +export interface HomeBackdrop { + source: ReadonlySignal; + report: BuildReporter; +} + +export function useHomeBackdrop(): HomeBackdrop { + // Per landing visit, like the city it feeds: a second one would be a second + // wallpaper, showing whatever IT picked. + const backdrop = useMemo(() => { + const source = signal(null); + // Named only once it lands: BACKDROP_CITY fades the canvas in, and a fade + // that starts on the manifest fades in an empty canvas. + let painting: BackdropCity | null = null; + const report: BuildReporter = { + ...SILENT_BUILD_REPORTER, + markIdle: () => { + BACKDROP_CITY.value = painting; + }, + }; + const show = (manifest: Manifest, city: BackdropCity): void => { + painting = city; + source.value = manifest; + }; + return { source: source as ReadonlySignal, report, show }; + }, []); + useEffect(() => { const controller = new AbortController(); // One go each, so a re-run cannot re-fetch a failure: this is what lets @@ -91,7 +124,7 @@ export function useHomeBackdrop(): void { const tried = new Set(); let inFlight = false; - async function tryNext(handle: SceneHandle, featuredRepo?: string): Promise { + async function tryNext(featuredRepo?: string): Promise { const signal = controller.signal; const next = candidates(featuredRepo).find((c) => !tried.has(`${c.kind}:${c.src}`)); if (!next) return; @@ -103,31 +136,26 @@ export function useHomeBackdrop(): void { // Too big to build without freezing the page, or nothing there at all: // either way this candidate is out and the next one gets its turn. if (!manifest || !fitsBehindTheLanding(manifest)) { - void tryNext(handle, featuredRepo); + void tryNext(featuredRepo); return; } - await handle.applyManifest(manifest); - if (signal.aborted) return; - BACKDROP_CITY.value = { + backdrop.show(manifest, { src: next.src, label: manifest.tree?.name ?? next.src, // Normalised the way commitSource does: identity includes the // branch, or the repo won't match its own row in recents. branch: identityBranch(next.src, resolveBranch(manifest, next.branch)), kind: next.kind, - }; + }); } finally { inFlight = false; } } + // Re-runs when the server config lands, which gives featured its turn. const stop = effect(() => { - // The landing's own canvas, which mounts with the view: leaving the route - // takes it (and the scene) with it, so there is nothing to hand back. - const handle = BACKDROP_HANDLE.value; - // Re-runs when the server config lands, which gives featured its turn. const featured = SERVER_CONFIG.value.featuredRepo; - if (handle && !inFlight && !BACKDROP_CITY.peek()) void tryNext(handle, featured); + if (!inFlight && !BACKDROP_CITY.peek()) void tryNext(featured); }); return () => { @@ -136,4 +164,6 @@ export function useHomeBackdrop(): void { BACKDROP_CITY.value = null; }; }, []); + + return backdrop; } diff --git a/app/src/hooks/useManifestSource.ts b/app/src/hooks/useManifestSource.ts index d03bdced..33d0c064 100644 --- a/app/src/hooks/useManifestSource.ts +++ b/app/src/hooks/useManifestSource.ts @@ -21,6 +21,7 @@ import { SOURCE_ERROR, commitSource, CURRENT_SOURCE, + isOpenedSource, activeExcludePathsFor, ACTIVE_EXCLUDES, } from '@/state/stores/source'; @@ -34,14 +35,7 @@ import { CITY_ON_SCREEN, } from '@/state/stores/progress'; import { TIMELINE_MODE, resetTimelineMode } from '@/state/stores/timeline'; -import { - srcKind, - SourceKind, - identityBranch, - sourceKey, - sameSourceIdentity, - sourceIdentity, -} from '@/utils/sources'; +import { srcKind, SourceKind, identityBranch, sourceKey, sourceIdentity } from '@/utils/sources'; import { readUrlView, type UrlView } from '@/router/viewParams'; import { ROUTE_PARAMS, ROUTE_PATH } from '@/router/location'; import { ROUTES } from '@/router/paths'; @@ -163,11 +157,10 @@ export async function loadSource(payload: SourcePayload): Promise { // What a cancel rolls back to, captured before the clear below: otherwise the // canceled repo's geometry lingers under the unchanged header. const prevManifest = MANIFEST.peek(); - // A DIFFERENT project retires the one on screen, and so does a re-open with - // none up: the manifest in hand builds a city this load is about to replace. - const opened = CURRENT_SOURCE.peek(); - const sameProject = !!opened && sameSourceIdentity(opened, { src: payload.src, branch }); - if (!sameProject || !CITY_ON_SCREEN.peek()) setManifest(null); + // What is on screen stays only while re-scanning the very project it shows: + // otherwise the manifest in hand builds a city this load is about to replace. + const keepCityUp = isOpenedSource(payload.src, branch) && CITY_ON_SCREEN.peek(); + if (!keepCityUp) setManifest(null); try { const url = manifestUrlFor({ @@ -390,32 +383,23 @@ const URL_SOURCE = computed(() => { /** Load whatever project the URL names, whenever that changes: the boot read * and every Back/Forward between cities are the same event. Returns a dispose. */ export function attachRouteLoad(): () => void { - // Whether the last thing this saw was a page with no city on it: opening one - // from there is a fresh ask, and only the server knows if its scan holds. - let away = true; + // Whether this visit to the city route has already asked the server. Leaving + // clears it: only the server can say whether an hour-old scan still holds. + let askedThisVisit = false; return effect(() => { if (!URL_SOURCE.value) { - away = true; + askedThisVisit = false; return; } - const reopened = away; - away = false; // Peeked: the identity above is the trigger, and re-reading the params here // must not subscribe this to the view ones alongside it. const boot = readUrlView(ROUTE_PARAMS.peek()); // Out of the tracking scope: the load writes signals this effect reads. queueMicrotask(() => { - // Never left, same project: committing a remote puts the branch the - // server resolved in the URL, which moves the identity above by itself. - const cur = CURRENT_SOURCE.peek(); - if ( - !reopened && - cur && - boot.src && - sameSourceIdentity(cur, { src: boot.src, branch: boot.branch }) - ) { - return; - } + // Already asked, and the URL still names what came back: committing a + // remote puts a branch in the URL, moving the identity above by itself. + if (askedThisVisit && isOpenedSource(boot.src, boot.branch)) return; + askedThisVisit = true; void bootLoad(boot); }); }); diff --git a/app/src/hooks/useTimelineMode.ts b/app/src/hooks/useTimelineMode.ts index 03ab76c9..afc226bb 100644 --- a/app/src/hooks/useTimelineMode.ts +++ b/app/src/hooks/useTimelineMode.ts @@ -24,7 +24,6 @@ import { setLoadingStep, setLoadingStepTail, hideLoadingOverlay, - whenCityOnScreen, setLoadingCancel, PENDING_SOURCE_LABEL, } from '@/state/stores/progress'; @@ -180,7 +179,7 @@ export async function loadTimelineSource({ if (overlay) { // The pack returns before its meshes are drawn: wait for the frame that // carries them, or the reveal lands on a half-built city. - await whenCityOnScreen(); + await handle.whenOnScreen(); hideLoadingOverlay(); } } catch (err) { diff --git a/app/src/state/settings/reactions.ts b/app/src/state/settings/reactions.ts index 7f95e2e3..4702781d 100644 --- a/app/src/state/settings/reactions.ts +++ b/app/src/state/settings/reactions.ts @@ -6,21 +6,17 @@ import { computed, effect, untracked } from '@preact/signals'; import { RebuildStatus, type BuildReporter } from '@/state/stores/progress'; import { routeSignature, ChangeRoute } from '@/state/settings/schema'; -import type { Manifest } from '@/types'; +import type { City } from '@/city/types'; // Min-dwell for the 'rebuilding' indicator on the material-only path. const HOT_REBUILD_MIN_DWELL_MS = 220; interface SettingsReactionsOpts { - /** Re-pack the current mode's scene (Live: HEAD; Timeline: union + scrub), so a - * Save keeps the user in the mode they're in. */ - rebuildScene(): Promise; - invalidateLayoutCache(): void; - /** What the city being rebuilt is showing, or null when there is nothing to - * rebuild. Per city: two of them are two different answers. */ - currentManifest(): Manifest | null; - /** This city's status channel, so a Save to a city nobody is waiting for - * cannot drive the readouts describing the one they are. */ + /** The city to re-pack. It knows what it is showing and how to rebuild it, + * which is why nothing here has to be told either. */ + city: Pick; + /** That city's status channel, so a Save to one nobody is waiting for cannot + * drive the readouts describing the one they are. */ report: BuildReporter; } @@ -29,12 +25,7 @@ interface SettingsReactionsOpts { const REBUILD_SIGNATURE = computed(() => routeSignature(ChangeRoute.Rebuild)); const REFRESH_SIGNATURE = computed(() => routeSignature(ChangeRoute.Refresh)); -export function attachSettingsReactions({ - rebuildScene, - invalidateLayoutCache, - currentManifest, - report, -}: SettingsReactionsOpts): () => void { +export function attachSettingsReactions({ city, report }: SettingsReactionsOpts): () => void { // Effects fire synchronously on first call. Suppress reactions until all // subscriptions are wired so the initial fire doesn't trigger a rebuild. let armed = false; @@ -47,12 +38,12 @@ export function attachSettingsReactions({ try { // Always invalidate: the manifest is unchanged, so reuseLayoutFrom would // skip the recompute and the setting would do nothing visible. - invalidateLayoutCache(); - if (!currentManifest()) { - report.markIdle(); // no world to rebuild — settle immediately + city.invalidateLayoutCache(); + if (!city.manifest.peek()) { + report.markIdle(); // nothing on it to rebuild — settle immediately return; } - await rebuildScene(); + await city.repack(); // Idle is owned by the trees decoration pass (markIdle), the last stage of // applyManifest — setting it here would stomp its Decorating state. } catch (err) { diff --git a/app/src/state/stores/progress.ts b/app/src/state/stores/progress.ts index d6634547..0baae353 100644 --- a/app/src/state/stores/progress.ts +++ b/app/src/state/stores/progress.ts @@ -121,34 +121,13 @@ export function markIdle(): void { /** The canvas went away (leaving `/city` unmounts it), so nothing is on screen: * the remount's rebuild is a load with a world to wait for. */ -export function markSceneGone(): void { +function markGone(): void { REBUILD_STATUS.value = RebuildStatus.Pending; BUILT_MANIFEST.value = null; REBUILD_DETAIL.value = null; BUILD_PROGRESS.value = null; } -/** Resolves once the build in flight has a frame on screen, or has failed: a - * city that will never paint must not strand the caller waiting for it. */ -export function whenCityOnScreen(): Promise { - return new Promise((resolve) => { - let stop: (() => void) | null = null; - let settled = false; - stop = effect(() => { - const status = REBUILD_STATUS.value; - if (status !== RebuildStatus.Idle && status !== RebuildStatus.Error) return; - if (settled) return; - settled = true; - // Out of the effect's own run: `stop` is still being assigned when this - // resolves on the first pass. - queueMicrotask(() => { - stop?.(); - resolve(); - }); - }); - }); -} - export function markError(err: unknown): void { REBUILD_STATUS.value = RebuildStatus.Error; // Logged with the stack, where a developer can use it. The UI shows a generic @@ -203,12 +182,14 @@ export interface BuildReporter { markDecorating(): void; markIdle(): void; markError(err: unknown): void; + /** The canvas went away, so whatever it had on it is not on screen. */ + markGone(): void; /** Read back, for a caller checking whether its own flash still stands. */ status(): RebuildStatus; } /** The project you opened: its build is the wait every readout is about. */ -export const WORLD_BUILD_REPORTER: BuildReporter = { +export const OPENED_PROJECT_REPORTER: BuildReporter = { beginBuild, enterBuildStage, setBuildStagePercent, @@ -216,6 +197,7 @@ export const WORLD_BUILD_REPORTER: BuildReporter = { markDecorating, markIdle, markError, + markGone, status: () => REBUILD_STATUS.peek(), }; @@ -228,6 +210,7 @@ export const SILENT_BUILD_REPORTER: BuildReporter = { markRebuilding: () => {}, markDecorating: () => {}, markIdle: () => {}, + markGone: () => {}, // Logged, never surfaced: a city nobody is waiting for has no readout to // fail in, and a silent throw is how a broken wallpaper goes unnoticed. markError: (err) => console.error('[codecity] scenery build failed', err), diff --git a/app/src/state/stores/source.ts b/app/src/state/stores/source.ts index 69d1ea19..0383bb49 100644 --- a/app/src/state/stores/source.ts +++ b/app/src/state/stores/source.ts @@ -41,6 +41,13 @@ export const ACTIVE_SOURCE = computed<{ src: string; branch?: string } | null>(( return backdrop ? { src: backdrop.src, branch: backdrop.branch } : null; }); +/** Whether these name the project already committed: the one question three + * call sites were each asking of CURRENT_SOURCE by hand. */ +export function isOpenedSource(src?: string | null, branch?: string | null): boolean { + const cur = CURRENT_SOURCE.peek(); + return !!cur && !!src && sameSourceIdentity(cur, { src, branch: branch ?? undefined }); +} + /** The loaded source's stable hash, or null. Namespaces per-source storage. */ export const CURRENT_SOURCE_KEY = computed(() => CURRENT_SOURCE.value ? sourceKey(CURRENT_SOURCE.value.src, CURRENT_SOURCE.value.branch) : null @@ -67,10 +74,7 @@ effect(() => { // A different project than the URL was describing: its mode, scrub commit // and selection belong to the one that just left. const had = params.get(URL_PARAMS.SRC); - if ( - had && - !sameSourceIdentity({ src: had, branch: params.get(URL_PARAMS.BRANCH) ?? undefined }, cur) - ) { + if (had && !isOpenedSource(had, params.get(URL_PARAMS.BRANCH) ?? undefined)) { for (const key of Object.values(VIEW_PARAMS)) params.delete(key); } params.set(URL_PARAMS.SRC, cur.src); diff --git a/app/src/views/CityView/chrome/CityStage/CityStage.tsx b/app/src/views/CityView/chrome/CityStage/CityStage.tsx index 3a9221df..c97c6a80 100644 --- a/app/src/views/CityView/chrome/CityStage/CityStage.tsx +++ b/app/src/views/CityView/chrome/CityStage/CityStage.tsx @@ -3,6 +3,7 @@ import './CityStage.css'; import { City } from '@/city/City'; +import { OPENED_PROJECT } from '@/city/openedProject'; import { TimelineScrubber } from '@/components/timeline/TimelineScrubber/TimelineScrubber'; import { TimelineToggle } from '@/components/timeline/TimelineToggle/TimelineToggle'; import { SelectionChip } from '@/views/CityView/chrome/CityStage/SelectionChip/SelectionChip'; @@ -10,7 +11,9 @@ import { SelectionChip } from '@/views/CityView/chrome/CityStage/SelectionChip/S export function CityStage() { return (
- + {/* Opaque: nothing sits behind this canvas, so a resize gap would flash + the page through it. */} +
diff --git a/app/src/views/HomeView/HomeView.tsx b/app/src/views/HomeView/HomeView.tsx index 86015471..5e938643 100644 --- a/app/src/views/HomeView/HomeView.tsx +++ b/app/src/views/HomeView/HomeView.tsx @@ -6,7 +6,8 @@ import './HomeView.css'; import { useState } from 'preact/hooks'; import { Waypoints, Building2, TreePine, Sparkles, History, Compass } from 'lucide-preact'; -import { City, CityVariant } from '@/city/City'; +import { City } from '@/city/City'; +import { CameraMode } from '@/city/render/cameraRig'; import { GemIcon } from '@/components/app/GemIcon/GemIcon'; import { MetaLine } from '@/components/app/MetaLine/MetaLine'; import { type SourcePayload } from '@/types/ui'; @@ -24,7 +25,7 @@ const SOURCE_TAB = { recents: 'recents', discover: 'discover' } as const; const SOURCE_PANEL_ID = 'landing-sources'; export function HomeView() { - useHomeBackdrop(); + const backdrop = useHomeBackdrop(); // Navigate, don't load: the URL carrying a src IS the load trigger, the same // one a deep link uses. Loading here waited for the scan before routing. const open = (payload: SourcePayload): void => navigate(cityHref(payload.src, payload.branch)); @@ -53,7 +54,12 @@ export function HomeView() { {/* Wallpaper first, the city over it once one paints: here the canvas is decoration, so it carries no chrome and no controls. */}
diff --git a/app/tests/_helpers/cityFixtures.ts b/app/tests/_helpers/cityFixtures.ts index d5c52301..4d8dfa39 100644 --- a/app/tests/_helpers/cityFixtures.ts +++ b/app/tests/_helpers/cityFixtures.ts @@ -10,7 +10,7 @@ import type { Picker } from '@/city/interaction/picker'; import { TREES } from '@/state/settings/fields/trees'; import { BUILDING_DIMENSIONS } from '@/state/settings/fields/buildings'; import { createCityState, type CityState } from '@/city/state'; -import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; import { commits } from './commits'; // A no-op layout client, for tests that never call applyManifest and so never @@ -31,7 +31,7 @@ export function makeCityState(): CityState { return createCityState( STUB_LAYOUT_CLIENT as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); } diff --git a/app/tests/city/applyManifestScenic.test.ts b/app/tests/city/applyManifestScenic.test.ts index 77a350ed..9396c589 100644 --- a/app/tests/city/applyManifestScenic.test.ts +++ b/app/tests/city/applyManifestScenic.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { createCityState } from '@/city/state'; -import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; import { createStreets } from '@/city/components/streets'; import { NodeKind, StreetAxis } from '@/types'; import type { CityLayout, DateRanges, Manifest, Street } from '@/types'; @@ -74,7 +74,7 @@ describe('cityState.applyManifest — scenic reactivity parity', () => { const cityState = createCityState( layoutClient as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); const streets = createStreets(makeSceneContext(cityState)); disposers.push(() => streets.dispose()); diff --git a/app/tests/city/builtCity.test.ts b/app/tests/city/builtCity.test.ts index f43664ec..cdfed8f6 100644 --- a/app/tests/city/builtCity.test.ts +++ b/app/tests/city/builtCity.test.ts @@ -35,7 +35,7 @@ vi.mock('@/city/components/buildings/atlas', async () => { }); import { createCity } from '@/city/index'; -import { WORLD_BINDINGS } from '@/city/bindings'; +import { OPENED_PROJECT } from '@/city/openedProject'; const W = 800; const H = 600; @@ -89,7 +89,7 @@ describe('a built city is pickable', () => { } it('picks the building under the cursor once the build has finished', async () => { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); try { CURRENT_SOURCE.value = { src: 'test://repo' }; await handle.applyManifest(makeManifest()); @@ -121,7 +121,7 @@ describe('a built city is pickable', () => { // The load path end to end: URL → follow → selection → camera. The restore must // not swing overhead, so the pivot→camera offset survives the centring. it('centres a URL selection on the loaded framing, without turning the camera', async () => { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); try { SCENE_HANDLE.value = handle; navigate('/city?src=test%3A%2F%2Frepo&sel=file:src/a.ts', { replace: true }); diff --git a/app/tests/city/components/trees/index.test.ts b/app/tests/city/components/trees/index.test.ts index 2599a9ab..8e31dee2 100644 --- a/app/tests/city/components/trees/index.test.ts +++ b/app/tests/city/components/trees/index.test.ts @@ -8,7 +8,7 @@ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { createTrees } from '@/city/components/trees'; import { createCityState } from '@/city/state'; -import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; import { commitTarget, makeCityState, @@ -120,7 +120,7 @@ describe('createTrees() component door', () => { const cityState = createCityState( layoutClientFor(TREE_LAYOUT) as never, stubPlacementClient(PLACEMENTS) as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); const { ctx } = makePickableSceneContext(cityState); trees = createTrees(ctx); diff --git a/app/tests/city/index.test.ts b/app/tests/city/index.test.ts index a9e99f59..afd1ed29 100644 --- a/app/tests/city/index.test.ts +++ b/app/tests/city/index.test.ts @@ -15,7 +15,7 @@ vi.mock('three', async () => { vi.mock('@/city/render/postFx', async () => (await import('../_helpers/threeMock')).postFxMock()); import { createCity } from '@/city/index'; -import { WORLD_BINDINGS } from '@/city/bindings'; +import { OPENED_PROJECT } from '@/city/openedProject'; describe('createCity', () => { let rafSpy: ReturnType; @@ -48,7 +48,7 @@ describe('createCity', () => { } it('builds with no manifest and returns the expected handle shape', async () => { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); expect(handle.world).toBeDefined(); expect(handle.picker).toBeDefined(); @@ -56,7 +56,7 @@ describe('createCity', () => { }); it('dispose() releases the WebGL context (forceContextLoss), not just its resources', async () => { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); expect(forceContextLossSpy).not.toHaveBeenCalled(); handle.dispose(); expect(forceContextLossSpy).toHaveBeenCalled(); @@ -68,7 +68,7 @@ describe('createCity', () => { // Only the uninstall is asserted here: the rebuild it triggers needs a // populated manifest to observe, which this harness does not build. it('reacts to TIMELINE_MODE going true→false by uninstalling the controller', async () => { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); handle.timeline.installScrubController(new Map(), []); const uninstallSpy = vi.spyOn(handle.timeline, 'uninstallScrubController'); @@ -82,7 +82,7 @@ describe('createCity', () => { }); it('no-ops when no controller was ever installed', async () => { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); const uninstallSpy = vi.spyOn(handle.timeline, 'uninstallScrubController'); TIMELINE_MODE.value = true; diff --git a/app/tests/city/initialFraming.test.ts b/app/tests/city/initialFraming.test.ts index f944525a..08b646e6 100644 --- a/app/tests/city/initialFraming.test.ts +++ b/app/tests/city/initialFraming.test.ts @@ -29,7 +29,7 @@ vi.mock('@/city/components/buildings/atlas', async () => { }); import { createCity } from '@/city/index'; -import { WORLD_BINDINGS } from '@/city/bindings'; +import { OPENED_PROJECT } from '@/city/openedProject'; import type { City } from '@/city/types'; describe('initial-load framing (issue #62)', () => { @@ -90,7 +90,7 @@ describe('initial-load framing (issue #62)', () => { } it('frames the city on initial load, not the empty boot', async () => { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); try { // firstFrame framed the empty boot (no source committed yet → no snap). const bootPos = handle.rig.camera.position.clone(); @@ -117,7 +117,7 @@ describe('initial-load framing (issue #62)', () => { // The route split made this the normal order: the landing commits the // source, THEN the city view mounts a scene onto it. CURRENT_SOURCE.value = { src: 'test://repo' }; - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); try { const bootPos = handle.rig.camera.position.clone(); await build(handle, makeManifest()); @@ -135,7 +135,7 @@ describe('initial-load framing (issue #62)', () => { it('does not reframe on a same-source re-apply (live-update / config save)', async () => { setRootWidth(100); - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); try { CURRENT_SOURCE.value = { src: 'test://repo' }; const m = makeManifest(); diff --git a/app/tests/city/interaction/inputHandlers.test.ts b/app/tests/city/interaction/inputHandlers.test.ts index 079e8b11..47ad8d3c 100644 --- a/app/tests/city/interaction/inputHandlers.test.ts +++ b/app/tests/city/interaction/inputHandlers.test.ts @@ -20,7 +20,7 @@ vi.mock('@/city/render/postFx', async () => ); import { createCity } from '@/city/index'; -import { WORLD_BINDINGS } from '@/city/bindings'; +import { OPENED_PROJECT } from '@/city/openedProject'; describe('scene keydown handler — modal suppression', () => { let rafSpy: ReturnType; @@ -52,7 +52,7 @@ describe('scene keydown handler — modal suppression', () => { }); async function mountCity() { - const handle = await createCity(makeCanvas(), WORLD_BINDINGS); + const handle = await createCity(makeCanvas(), OPENED_PROJECT); cities.push(handle); return handle; } diff --git a/app/tests/city/state/applyManifestProgress.test.ts b/app/tests/city/state/applyManifestProgress.test.ts index 02962ebf..54eb94e9 100644 --- a/app/tests/city/state/applyManifestProgress.test.ts +++ b/app/tests/city/state/applyManifestProgress.test.ts @@ -6,7 +6,7 @@ import { stubPlacementClient } from '../../_helpers/cityFixtures'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { effect } from '@preact/signals'; import { createCityState } from '@/city/state'; -import { BUILD_PROGRESS, WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { BUILD_PROGRESS, OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; import { buildStageTail } from '@/constants/progress'; import { NodeKind } from '@/types'; import type { CityLayout, DateRanges, Manifest } from '@/types'; @@ -78,7 +78,7 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => const state = createCityState( fakeLayoutClient() as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); const tails = await tailsDuring(() => state.applyManifest(manifest('sig-1'))); @@ -91,7 +91,7 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => const state = createCityState( fakeLayoutClient() as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); await state.applyManifest(manifest('sig-1')); // Same structure signature: the atlas is already right for this tree, so @@ -105,7 +105,7 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => const state = createCityState( fakeLayoutClient([7, 61]) as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); const tails = await tailsDuring(() => state.applyManifest(manifest('sig-1'))); @@ -132,7 +132,7 @@ describe('cityState.applyManifest — the build says where it is (#185)', () => const state = createCityState( client as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); void state.applyManifest(manifest('sig-1')); diff --git a/app/tests/city/state/applyManifestReuse.test.ts b/app/tests/city/state/applyManifestReuse.test.ts index 730c6d31..08586dde 100644 --- a/app/tests/city/state/applyManifestReuse.test.ts +++ b/app/tests/city/state/applyManifestReuse.test.ts @@ -6,7 +6,7 @@ import { stubPlacementClient } from '../../_helpers/cityFixtures'; import { describe, it, expect, vi } from 'vitest'; import { createCityState } from '@/city/state'; -import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; import { NodeKind } from '@/types'; import type { CityLayout, DateRanges, Manifest } from '@/types'; @@ -68,7 +68,7 @@ describe('cityState.applyManifest — reuse gate keys on the layout signature (# const state = createCityState( fakeLayoutClient() as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); await state.applyManifest(manifest('L1')); const before = state.structureRevision.value; @@ -82,7 +82,7 @@ describe('cityState.applyManifest — reuse gate keys on the layout signature (# const state = createCityState( fakeLayoutClient() as never, stubPlacementClient() as never, - WORLD_BUILD_REPORTER + OPENED_PROJECT_REPORTER ); await state.applyManifest(manifest('L1', 10)); const before = state.structureRevision.value; diff --git a/app/tests/city/twoCities.test.ts b/app/tests/city/twoCities.test.ts index 0f094d15..23af05bd 100644 --- a/app/tests/city/twoCities.test.ts +++ b/app/tests/city/twoCities.test.ts @@ -34,7 +34,7 @@ vi.mock('@/city/components/buildings/atlas', async () => { }); import { createCity } from '@/city/index'; -import { WORLD_BINDINGS } from '@/city/bindings'; +import { OPENED_PROJECT } from '@/city/openedProject'; const W = 800; const H = 600; @@ -99,7 +99,7 @@ describe('two cities at once', () => { }); it('holds the bound one’s "on screen" while the unbound one builds beside it', async () => { - const world = await createCity(makeCanvas(), WORLD_BINDINGS); + const world = await createCity(makeCanvas(), OPENED_PROJECT); const scenery = await createCity(makeCanvas()); try { CURRENT_SOURCE.value = { src: 'test://repo' }; diff --git a/app/tests/city/worldLayoutCache.test.ts b/app/tests/city/worldLayoutCache.test.ts index 5a02770f..9cd29626 100644 --- a/app/tests/city/worldLayoutCache.test.ts +++ b/app/tests/city/worldLayoutCache.test.ts @@ -3,9 +3,10 @@ // is the fix: invalidate before applying, so a Save always re-packs. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { signal } from '@preact/signals'; import { attachSettingsReactions } from '@/state/settings/reactions'; import { setManifest } from '@/state/stores/manifest'; -import { WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; import { STREET_LAYOUT } from '@/state/settings/fields/streets'; import type { Manifest } from '@/types'; @@ -39,14 +40,16 @@ describe('attachSettingsReactions invalidates layout cache before applyManifest' setManifest(stubManifest as unknown as Manifest); detach = attachSettingsReactions({ - async rebuildScene() { - calls.push('rebuildScene'); + city: { + manifest: signal(stubManifest as unknown as Manifest), + async repack() { + calls.push('rebuildScene'); + }, + invalidateLayoutCache() { + calls.push('invalidateLayoutCache'); + }, }, - invalidateLayoutCache() { - calls.push('invalidateLayoutCache'); - }, - currentManifest: () => stubManifest as unknown as Manifest, - report: WORLD_BUILD_REPORTER, + report: OPENED_PROJECT_REPORTER, }); // What Save does: commit() fires setKey on the real store, which is what diff --git a/app/tests/hooks/useTimelineMode.test.ts b/app/tests/hooks/useTimelineMode.test.ts index 35dd1b9a..9f603c35 100644 --- a/app/tests/hooks/useTimelineMode.test.ts +++ b/app/tests/hooks/useTimelineMode.test.ts @@ -45,25 +45,19 @@ const BUNDLE = { note: null, } as unknown as TimelineBundle; -// The composer marks Idle a frame after the pack returns, and the reveal waits -// for that. Tracked, so an unfired frame can't settle the NEXT test's build. -let pendingSettle = 0; -function settleNextFrame(): void { - cancelPendingSettle(); - pendingSettle = requestAnimationFrame(() => { - pendingSettle = 0; - markIdle(); - }); -} -function cancelPendingSettle(): void { - if (pendingSettle) cancelAnimationFrame(pendingSettle); - pendingSettle = 0; -} - function fakeHandle() { - const applyManifest = vi.fn().mockImplementation(async () => { - settleNextFrame(); - }); + const applyManifest = vi.fn().mockResolvedValue(undefined); + // The real one resolves on the frame that carries the build, which is what + // the reveal waits for: one that resolves early would prove nothing. + const whenOnScreen = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + markIdle(); + resolve(); + }); + }) + ); // The hook opens the build's readout on the stages the apply will run, so a // handle without this can't be driven through a load. const buildStagesFor = vi.fn().mockReturnValue([BuildStage.Layout, BuildStage.Assemble]); @@ -73,6 +67,7 @@ function fakeHandle() { const setFootprintsTransparent = vi.fn(); const handle = { applyManifest, + whenOnScreen, buildStagesFor, // SELECTION_KEY reads through the handle's picker, so a handle without one // isn't a SceneHandle any consumer can hold. @@ -87,6 +82,7 @@ function fakeHandle() { return { handle, applyManifest, + whenOnScreen, buildStagesFor, installScrubController, uninstallScrubController, @@ -109,7 +105,6 @@ describe('loadTimelineScene', () => { (fetchTimelineBundle as unknown as ReturnType).mockReset(); }); afterEach(() => { - cancelPendingSettle(); TIMELINE_MODE.value = false; SCENE_HANDLE.value = null; }); @@ -182,15 +177,19 @@ describe('loadTimelineScene', () => { it('holds the reveal until the packed city has a frame on screen', async () => { (fetchTimelineBundle as unknown as ReturnType).mockResolvedValue(BUNDLE); const f = fakeHandle(); - // The pack resolves a frame before the city is presented. Sample the overlay + // The pack returns a frame before the city is presented. Sample the overlay // in that gap: lifting there is the bug this waits out. let visibleBeforeIdle: boolean | null = null; - f.applyManifest.mockImplementation(async () => { - requestAnimationFrame(() => { - visibleBeforeIdle = LOADING_OVERLAY.value.visible; - markIdle(); - }); - }); + f.whenOnScreen.mockImplementation( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + visibleBeforeIdle = LOADING_OVERLAY.value.visible; + markIdle(); + resolve(); + }); + }) + ); SCENE_HANDLE.value = f.handle as never; await loadTimelineScene(); @@ -417,7 +416,6 @@ describe('loadTimelineScene inPlace refetch', () => { (fetchTimelineBundle as unknown as ReturnType).mockReset(); }); afterEach(() => { - cancelPendingSettle(); TIMELINE_MODE.value = false; SCENE_HANDLE.value = null; }); diff --git a/app/tests/state/settings/reactions.test.ts b/app/tests/state/settings/reactions.test.ts index ed8c008c..52e6886d 100644 --- a/app/tests/state/settings/reactions.test.ts +++ b/app/tests/state/settings/reactions.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { signal } from '@preact/signals'; import { attachSettingsReactions } from '@/state/settings/reactions'; import { setManifest } from '@/state/stores/manifest'; -import { REBUILD_STATUS, RebuildStatus, WORLD_BUILD_REPORTER } from '@/state/stores/progress'; +import { REBUILD_STATUS, RebuildStatus, OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; import { TREES } from '@/state/settings/fields/trees'; import { GEM } from '@/state/settings/fields/gem'; import type { Manifest } from '@/types'; @@ -21,12 +22,14 @@ describe('attachSettingsReactions routing', () => { const manifest = { tree: {} } as unknown as Manifest; setManifest(manifest); detach = attachSettingsReactions({ - rebuildScene: async () => { - rebuildCalls++; + city: { + manifest: signal(manifest), + repack: async () => { + rebuildCalls++; + }, + invalidateLayoutCache: () => {}, }, - invalidateLayoutCache: () => {}, - currentManifest: () => manifest, - report: WORLD_BUILD_REPORTER, + report: OPENED_PROJECT_REPORTER, }); }); diff --git a/app/tests/state/stores/progress.test.ts b/app/tests/state/stores/progress.test.ts index e189e1a7..58b7645a 100644 --- a/app/tests/state/stores/progress.test.ts +++ b/app/tests/state/stores/progress.test.ts @@ -11,12 +11,12 @@ import { setBuildStagePercent, markDecorating, markIdle, - markSceneGone, BUILT_MANIFEST, CITY_ON_SCREEN, LOADING_OVERLAY, PENDING_SOURCE_LABEL, showLoadingOverlay, + OPENED_PROJECT_REPORTER, } from '@/state/stores/progress'; import { SourceKind } from '@/utils/sources'; @@ -296,7 +296,7 @@ describe('loadingReactions', () => { BUILT_MANIFEST.value = EMPTY_MANIFEST; // markIdle copies MANIFEST, empty here expect(CITY_ON_SCREEN.value).toBe(true); - markSceneGone(); + OPENED_PROJECT_REPORTER.markGone(); expect(CITY_ON_SCREEN.value).toBe(false); expect(REBUILD_STATUS.value).toBe(RebuildStatus.Pending); }); diff --git a/app/tests/views/HomeView/HomeView.test.tsx b/app/tests/views/HomeView/HomeView.test.tsx index 77bf34e9..44cd3b8e 100644 --- a/app/tests/views/HomeView/HomeView.test.tsx +++ b/app/tests/views/HomeView/HomeView.test.tsx @@ -7,10 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render } from 'preact'; // The backdrop canvas: jsdom has no WebGL, and none of this is about the scene. -vi.mock('@/city/City', () => ({ - City: () => null, - CityVariant: { Scene: 'scene', Backdrop: 'backdrop' }, -})); +vi.mock('@/city/City', () => ({ City: () => null })); // Typing a remote URL mounts BranchSelect, which asks the API for branches: left // real, that rejects "fetch failed" into whichever LATER test is running by then. From c028bac4f230b70c6bfa95776cb608ea3d32ef5c Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 14:32:20 -0400 Subject: [PATCH 07/37] Name the route-load flag after arriving, and say which way a city talks `askedThisVisit` named a period nobody had defined. The fact is simpler: you arrive at the city route, and arriving asks the server. Moving around inside it does not, because the only identity change there is the branch a commit resolves into the URL. And whenOnScreen vs report.markIdle is one moment with two exits, which now says so where you'd meet it: the reporter is the city telling whoever mounted it, the handle is whoever holds it asking. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/city/types/index.ts | 7 ++++--- app/src/hooks/useManifestSource.ts | 17 +++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/src/city/types/index.ts b/app/src/city/types/index.ts index ede8ee2e..5f0f7e05 100644 --- a/app/src/city/types/index.ts +++ b/app/src/city/types/index.ts @@ -85,7 +85,8 @@ export interface CityTimelineBinding { export interface CityBindings { /** Camera behaviour: what this city is for. */ cameraMode?: CameraMode; - /** Where its build status goes. Left out, it reports to nobody. */ + /** Where this city announces its build, for whoever mounted it. Left out, it + * reports to nobody. Questions go the other way, through the handle. */ report?: BuildReporter; /** What it is a picture OF, as one comparable string. The camera refits when * it changes, so skeleton→heights→history frames once, not three times. */ @@ -106,8 +107,8 @@ export interface City { /** Re-pack what this city is already showing: the settings path, where the * manifest has not moved and only the packer's inputs have. */ repack(): Promise; - /** Resolves once a frame carrying the latest build has been presented. There - * is no such callback in WebGL, so the composer keeps this one. */ + /** Resolves once a frame carrying the latest build has been presented, which + * WebGL will not tell you. `report.markIdle`'s moment, asked instead of told. */ whenOnScreen(): Promise; world: CityWorld; /** Timeline-mode install surface (see hooks/useTimelineMode). The controller diff --git a/app/src/hooks/useManifestSource.ts b/app/src/hooks/useManifestSource.ts index 33d0c064..c5f3e44c 100644 --- a/app/src/hooks/useManifestSource.ts +++ b/app/src/hooks/useManifestSource.ts @@ -383,23 +383,24 @@ const URL_SOURCE = computed(() => { /** Load whatever project the URL names, whenever that changes: the boot read * and every Back/Forward between cities are the same event. Returns a dispose. */ export function attachRouteLoad(): () => void { - // Whether this visit to the city route has already asked the server. Leaving - // clears it: only the server can say whether an hour-old scan still holds. - let askedThisVisit = false; + // Whether the URL named a city on the last run, so this one can tell arriving + // at one from moving around inside it. + let onCityRoute = false; return effect(() => { if (!URL_SOURCE.value) { - askedThisVisit = false; + onCityRoute = false; return; } + const arriving = !onCityRoute; + onCityRoute = true; // Peeked: the identity above is the trigger, and re-reading the params here // must not subscribe this to the view ones alongside it. const boot = readUrlView(ROUTE_PARAMS.peek()); // Out of the tracking scope: the load writes signals this effect reads. queueMicrotask(() => { - // Already asked, and the URL still names what came back: committing a - // remote puts a branch in the URL, moving the identity above by itself. - if (askedThisVisit && isOpenedSource(boot.src, boot.branch)) return; - askedThisVisit = true; + // Arriving always asks: only the server knows if its scan still holds. + // Once here, that project again is the branch a commit put in the URL. + if (!arriving && isOpenedSource(boot.src, boot.branch)) return; void bootLoad(boot); }); }); From a84b405839cec5c21b222678c4c45afb560aba82 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 22 Aug 2026 15:02:32 -0400 Subject: [PATCH 08/37] Make project state per-instance: one session, not one app's worth of globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MANIFEST, CURRENT_SOURCE, the scan and rebuild status, the timeline and its scrub position, the scene handle and the fetch layer's generation were all module singletons, so however independent a city instance was, two of them shared one project's worth of state. Every one of those now belongs to a ProjectSession. The four stores are factories taking the stores they read (manifest -> source -> progress, manifest -> timeline), which makes the dependency graph explicit instead of ambient. The loader and the timeline controller are per session too, so the load generation, the abort controller and the injected refresh/boot handlers are that session's — and the handler injection (setTimelineRefreshHandler and friends) is gone, since both sides can reach each other through the session they belong to. The chrome reads its project from a provider, not an import: wrap the app in one session today, wrap each column of a side-by-side view in its own later, and the same components work either way. The URL is now one adapter pointed at one session (router/urlBinding), rather than the thing that owns which project exists. Everything else falls out of that. SCENE_HANDLE's verbs became a session's commands. PICKER_SELECTION_KEY moved onto the picker that owns the selection. LIVE_UPDATES_ACTIVE and the settings-transfer excludes part became questions about a session rather than about the app. The city internals that read the timeline directly (facade panels, the picker, the scrub controller and friends) take it through SceneContext. src typechecks; the test suite is converted in the commit after this. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/App.tsx | 79 +- app/src/city/City.tsx | 2 +- app/src/city/capture/captureHarness.ts | 17 +- app/src/city/capture/shots.ts | 18 +- .../city/components/buildings/cellAssembly.ts | 11 +- .../city/components/buildings/dataFacade.ts | 13 +- .../city/components/buildings/facadePanels.ts | 33 +- app/src/city/components/buildings/fader.ts | 7 +- app/src/city/components/buildings/index.ts | 15 +- app/src/city/forProject.ts | 29 + app/src/city/index.ts | 12 +- app/src/city/interaction/inputHandlers.ts | 27 +- app/src/city/interaction/picker.ts | 52 +- app/src/city/interaction/tooltipText.ts | 9 +- app/src/city/openedProject.ts | 34 - app/src/city/sceneHandle.ts | 256 +- app/src/city/timeline/scrubController.ts | 9 +- app/src/city/timeline/scrubFrame.ts | 9 +- app/src/city/types/index.ts | 11 +- .../loading/LoadingOverlay/LoadingOverlay.tsx | 9 +- .../LoadingProgress/LoadingProgress.tsx | 5 +- .../ImportExportMenu/ImportExportMenu.tsx | 36 +- .../FreshnessStatus/FreshnessStatus.tsx | 22 +- .../components/menus/ScanMenu/ScanMenu.tsx | 19 +- .../components/panes/PaneStats/statItems.ts | 9 +- .../sources/DiscoverList/DiscoverList.tsx | 5 +- .../sources/NewProjectForm/NewProjectForm.tsx | 5 +- .../sources/RecentsList/RecentsList.tsx | 5 +- .../TimelineScrubber/TimelineScrubber.tsx | 40 +- .../TimelineToggle/TimelineToggle.tsx | 13 +- app/src/hooks/useDocumentTitle.ts | 7 +- app/src/hooks/useHomeBackdrop.ts | 25 +- app/src/hooks/useManifestSource.ts | 623 ++--- app/src/hooks/useTimelineMode.ts | 466 ++-- app/src/main.tsx | 6 - app/src/router/urlBinding.ts | 58 + app/src/router/viewBinding.ts | 214 +- app/src/state/project/context.tsx | 31 + app/src/state/project/session.ts | 61 + app/src/state/settings/fields/updates.ts | 11 +- app/src/state/settings/transfer.ts | 69 +- app/src/state/stores/manifest.ts | 30 +- app/src/state/stores/progress.ts | 577 ++-- app/src/state/stores/source.ts | 277 +- app/src/state/stores/timeline.ts | 415 +-- app/src/types/manifest.generated.ts | 2393 +++++++++-------- app/src/views/CityView/CityView.tsx | 32 +- .../SelectionAnnouncer/SelectionAnnouncer.tsx | 7 +- .../CityView/chrome/CityHeader/CityHeader.tsx | 8 +- .../CitySidebarLeft/CitySidebarLeft.tsx | 32 +- .../CitySidebarRight/CitySidebarRight.tsx | 80 +- .../CityView/chrome/CityStage/CityStage.tsx | 6 +- .../CityStage/SelectionChip/SelectionChip.tsx | 8 +- .../panes/ExplorePane/ExplorePane.tsx | 8 +- .../ExplorePane/tabs/ReadmeTab/ReadmeTab.tsx | 7 +- .../panes/FilePreviewPane/FilePreviewPane.tsx | 23 +- .../CityView/panes/InfoPane/InfoPane.tsx | 5 +- .../InfoPane/tabs/OverviewTab/OverviewTab.tsx | 13 +- app/src/views/HomeView/HomeView.tsx | 6 +- 59 files changed, 3319 insertions(+), 2990 deletions(-) create mode 100644 app/src/city/forProject.ts delete mode 100644 app/src/city/openedProject.ts create mode 100644 app/src/router/urlBinding.ts create mode 100644 app/src/state/project/context.tsx create mode 100644 app/src/state/project/session.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index b6c854c0..631e419b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,50 +1,77 @@ -// App.tsx — the composition root: the routes, and the few reactions -// that outlive any one of them. Each view owns its own layout, its own hooks, -// and the canvas it mounts. +// App.tsx — the composition root: the focused project, the routes, and the few +// reactions that outlive any one of them. The session is created here and +// provided to everything below, so the chrome reads one project rather than a +// global. A side-by-side view would create several and provide one per column. import './App.css'; -import { useEffect } from 'preact/hooks'; +import { useEffect, useMemo } from 'preact/hooks'; import { useSignalEffect } from '@preact/signals'; import { Router, Route, Switch, Redirect } from 'wouter-preact'; import { HomeView } from '@/views/HomeView/HomeView'; import { CityView } from '@/views/CityView/CityView'; -import { SOURCE_ERROR } from '@/state/stores/source'; +import { createProjectSession } from '@/state/project/session'; +import { ProjectProvider } from '@/state/project/context'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useManifestSource } from '@/hooks/useManifestSource'; -import { attachOverlayDriver } from '@/state/stores/progress'; +import { attachUrlBinding } from '@/router/urlBinding'; +import { isDebugMode } from '@/utils/debugMode'; import { navigate, attachRouteHistory, useRouteLocation, useRouteSearch } from '@/router/location'; import { ROUTES } from '@/router/paths'; +import type { ProjectSession } from '@/state/project/session'; export function App() { - // The title spans both routes; everything a single view needs is mounted by - // that view. - useDocumentTitle(); - useManifestSource(); + // One project, for as long as the app is up. Everything below reads it + // through the provider; nothing reads it from a module. + const session = useMemo(() => createProjectSession(), []); + useEffect(() => () => session.dispose(), [session]); // Before anything that reads the URL, so back/forward is never missed. useEffect(() => attachRouteHistory(), []); - useEffect(() => attachOverlayDriver(), []); + // The URL describes THIS session: one adapter, pointed at one project. + useEffect(() => attachUrlBinding(session), [session]); + + return ( + + + + + + ); +} + +// Inside the provider, so everything it mounts can read the session it belongs +// to rather than being handed pieces of it. +function AppRoutes({ session }: { session: ProjectSession }) { + useDocumentTitle(); + useManifestSource(session); + + useEffect(() => session.progress.attachOverlayDriver(), [session]); + + // Debug-only README screenshot capture, when opened with ?shot=. + // Imported dynamically so the harness never ships in a normal session. + useEffect(() => { + if (!new URLSearchParams(window.location.search).has('shot') || !isDebugMode()) return; + void import('@/city/capture/captureHarness').then((m) => m.initCaptureHarness(session)); + }, [session]); - // A failure sends you back to the landing, which reads SOURCE_ERROR itself to + // A failure sends you back to the landing, which reads the error itself to // explain what happened. useSignalEffect(() => { - if (SOURCE_ERROR.value) navigate(ROUTES.HOME); + if (session.source.error.value) navigate(ROUTES.HOME); }); return ( - - - - - - - - - - - - - + + + + + + + + + + + ); } diff --git a/app/src/city/City.tsx b/app/src/city/City.tsx index 2620c00e..5dd4660c 100644 --- a/app/src/city/City.tsx +++ b/app/src/city/City.tsx @@ -68,7 +68,7 @@ export function City({ if (!m) return; // nothing to show yet // Scrubbing owns the contents while it runs. Peeked, so leaving the // mode doesn't repack what it committed (the teardown owns that). - if (bindings.timeline?.mode.peek()) return; + if (bindings.timeline?.store.mode.peek()) return; report.markRebuilding(); void made.applyManifest(m).catch(report.markError); }); diff --git a/app/src/city/capture/captureHarness.ts b/app/src/city/capture/captureHarness.ts index 6ecac81e..ecf12831 100644 --- a/app/src/city/capture/captureHarness.ts +++ b/app/src/city/capture/captureHarness.ts @@ -6,9 +6,8 @@ import { effect } from '@preact/signals'; import { isDebugMode } from '@/utils/debugMode'; -import { SCENE_HANDLE } from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { REBUILD_STATUS, RebuildStatus } from '@/state/stores/progress'; +import { RebuildStatus } from '@/state/stores/progress'; +import type { ProjectSession } from '@/state/project/session'; import type { Manifest } from '@/types'; import { SHOTS, type ShotOverrides } from './shots'; @@ -20,7 +19,7 @@ const SETTLE_MS = 2200; const POSE_RETRY_MS = 400; const MAX_POSE_ATTEMPTS = 120; -export function initCaptureHarness(): void { +export function initCaptureHarness(session: ProjectSession): void { const params = new URLSearchParams(window.location.search); const shot = params.get('shot'); if (!shot || !isDebugMode()) return; @@ -46,9 +45,10 @@ export function initCaptureHarness(): void { let posed = false; const stop = effect(() => { - const handle = SCENE_HANDLE.value; - const manifest = MANIFEST.value as Manifest; - if (posed || !handle || !manifest || REBUILD_STATUS.value !== RebuildStatus.Idle) return; + const handle = session.city.value; + const manifest = session.manifest.current.value as Manifest; + if (posed || !handle || !manifest) return; + if (session.progress.rebuildStatus.value !== RebuildStatus.Idle) return; // A skeleton also reaches Idle, and framing on its root-street bbox locks // onto a close-up. Reading anchors subscribes to bbox, so this re-fires. if (handle.rig.captureAnchors().tallestHeight <= 0) return; @@ -63,7 +63,8 @@ export function initCaptureHarness(): void { const tryPose = () => { let ready = true; try { - ready = pose(h, MANIFEST.peek() as Manifest, overrides) !== false; + ready = + pose(h, session.manifest.current.peek() as Manifest, overrides, session) !== false; } catch (err) { console.error(`[capture] shot "${shot}" pose failed`, err); } diff --git a/app/src/city/capture/shots.ts b/app/src/city/capture/shots.ts index e207bc19..fa0a19ee 100644 --- a/app/src/city/capture/shots.ts +++ b/app/src/city/capture/shots.ts @@ -7,8 +7,7 @@ import type { SceneHandle } from '@/city/sceneHandle'; import { NodeKind, type Manifest, type DirNode } from '@/types'; import { CAMERA } from '@/state/settings/fields/camera'; import { CameraMode } from '@/city/render/cameraRig'; -import { TIMELINE_MODE, SCRUB_MAX, TIMELINE_BUNDLE, setScrubPos } from '@/state/stores/timeline'; -import { loadTimelineScene } from '@/hooks/useTimelineMode'; +import type { ProjectSession } from '@/state/project/session'; /** Set the default-view angle (degrees); the rig re-frames the whole city to * it. Elevation is height above the horizon, azimuth the swing around the gem. */ @@ -28,7 +27,9 @@ export interface ShotOverrides { export type ShotPose = ( handle: SceneHandle, manifest: Manifest, - o: ShotOverrides + o: ShotOverrides, + /** The project being shot: its history, for a shot that scrubs. */ + session: ProjectSession ) => boolean | void; /** A placed tree's bounds. A named commit often has no tree, so this walks @@ -109,17 +110,18 @@ export const SHOTS: Record = { // The city part-built, at the defaults. The load is async, so this returns // false until the mode and bundle are live and the harness retries. - timeline: (handle, _m, o) => { - if (!TIMELINE_MODE.peek()) { + timeline: (handle, _m, o, session) => { + const timeline = session.timeline; + if (!timeline.mode.peek()) { if (!_timelineKickedOff) { _timelineKickedOff = true; - void loadTimelineScene(); + void session.timelineMode.loadScene(); } return false; } - const bundle = TIMELINE_BUNDLE.peek(); + const bundle = timeline.bundle.peek(); if (!bundle || bundle.commits.length === 0) return false; - setScrubPos(Math.floor(SCRUB_MAX.peek() * 0.5)); + timeline.setScrubPos(Math.floor(timeline.scrubMax.peek() * 0.5)); angle(o.elev ?? 44, o.az ?? 32); handle.rig.reset(); }, diff --git a/app/src/city/components/buildings/cellAssembly.ts b/app/src/city/components/buildings/cellAssembly.ts index 20f15d18..26afce12 100644 --- a/app/src/city/components/buildings/cellAssembly.ts +++ b/app/src/city/components/buildings/cellAssembly.ts @@ -10,6 +10,7 @@ import { InstancedFacadePanels } from './facadePanels'; import { isMediaFile, isDataBuilding, isEmptyFile } from '@/utils/fileKind'; import { BUILDINGS } from '@/state/settings/fields/buildings'; import { BuildingIndex } from './buildingIndex'; +import type { TimelineStore } from '@/state/stores/timeline'; import type { Building, SourceRef } from '@/types/index'; export interface CellAssemblyOutput { @@ -26,7 +27,9 @@ export interface CellAssemblyOutput { export function buildCellsFromLayout( bounds: WorldBounds, buildings: Building[], - source: SourceRef | null + source: SourceRef | null, + /** This city's history: which blob a facade reads is a scrub question. */ + timeline: TimelineStore | null ): CellAssemblyOutput { const cellSize = SpatialGrid.computeOptimalCellSize(bounds); const grid = new SpatialGrid(bounds, cellSize); @@ -111,7 +114,11 @@ export function buildCellsFromLayout( let facadePanels: InstancedFacadePanels | null = null; const panelCount = mediaBuildings.length + binaryBuildings.length; if (panelCount > 0) { - facadePanels = new InstancedFacadePanels(Math.max(64, Math.ceil(panelCount * 1.5)), source); + facadePanels = new InstancedFacadePanels( + Math.max(64, Math.ceil(panelCount * 1.5)), + source, + timeline + ); for (const b of mediaBuildings) facadePanels.registerMediaBuilding(b); for (const b of binaryBuildings) facadePanels.registerBinaryBuilding(b); sceneRoot.add(facadePanels.mesh); diff --git a/app/src/city/components/buildings/dataFacade.ts b/app/src/city/components/buildings/dataFacade.ts index 9ebf638e..a169e506 100644 --- a/app/src/city/components/buildings/dataFacade.ts +++ b/app/src/city/components/buildings/dataFacade.ts @@ -5,7 +5,6 @@ import { fetchFileBytes } from '@/api/file'; import type { SourceRef } from '@/types'; -import { scrubbedBlobShaFor } from '@/state/stores/timeline'; import { FONT_EXTS, AUDIO_EXTS } from '@/constants/fileExtensions'; import { PANEL_TEX_SIZE } from './facadePanelTextureArray'; @@ -36,14 +35,16 @@ let _fontSeq = 0; export async function renderFontGlyphFacade( source: SourceRef, path: string, - version: string + version: string, + /** The blob to read, when a scrub is pointing at one rather than at HEAD. */ + blobSha: string | null ): Promise { if (typeof FontFace === 'undefined' || typeof document.fonts === 'undefined') return null; const surface = _canvas(); if (!surface) return null; let face: FontFace | null = null; try { - const buf = await fetchFileBytes(source, path, version, scrubbedBlobShaFor(path)); + const buf = await fetchFileBytes(source, path, version, blobSha); const family = `cc-facade-font-${(_fontSeq += 1)}`; face = await new FontFace(family, buf).load(); document.fonts.add(face); @@ -79,13 +80,15 @@ function _getAudioCtx(): AudioContext | null { export async function renderWaveformFacade( source: SourceRef, path: string, - version: string + version: string, + /** The blob to read, when a scrub is pointing at one rather than at HEAD. */ + blobSha: string | null ): Promise { const audioCtx = _getAudioCtx(); const surface = _canvas(); if (!audioCtx || !surface) return null; try { - const buf = await fetchFileBytes(source, path, version, scrubbedBlobShaFor(path)); + const buf = await fetchFileBytes(source, path, version, blobSha); // decodeAudioData detaches its input, so hand it a copy. const audio = await audioCtx.decodeAudioData(buf.slice(0)); const data = audio.getChannelData(0); diff --git a/app/src/city/components/buildings/facadePanels.ts b/app/src/city/components/buildings/facadePanels.ts index 1820c60b..93b46663 100644 --- a/app/src/city/components/buildings/facadePanels.ts +++ b/app/src/city/components/buildings/facadePanels.ts @@ -15,12 +15,7 @@ import { FacadePanelTextureArray, MAX_PAGES as FACADE_PANEL_MAX_PAGES, } from './facadePanelTextureArray'; -import { - SETTLED_COMMIT, - TIMELINE_MODE, - hasNoContentAtScrub, - scrubbedBlobShaFor, -} from '@/state/stores/timeline'; +import type { TimelineStore } from '@/state/stores/timeline'; import { ContentPendingError, fetchFileBlob, fileUrl, isContentPending } from '@/api/file'; import { fingerprintUrl } from '@/api/fingerprint'; import { dataFacadeKind, renderFontGlyphFacade, renderWaveformFacade } from './dataFacade'; @@ -90,8 +85,8 @@ const LIVE_STAMP = -1; /** What a load of this building would fetch right now. Untracked: a rebuild * computes it, and must not come back to life on every scrub. */ -function versionKeyFor(b: Building): string { - return untracked(() => scrubbedBlobShaFor(b.file?.path) ?? b.file?.modified ?? ''); +function versionKeyFor(b: Building, timeline: TimelineStore | null): string { + return untracked(() => timeline?.scrubbedBlobShaFor(b.file?.path) ?? b.file?.modified ?? ''); } export class InstancedFacadePanels { @@ -158,10 +153,13 @@ export class InstancedFacadePanels { // The city's own source: paths are repo-relative, so a facade read is // meaningless without the repo they are relative to. readonly source: SourceRef | null, + // This city's history, when something scrubs it: which blob a facade reads + // is a question about where the scrubber is. + readonly timeline: TimelineStore | null, opts?: { onStartLoad?: (b: Building, layer: number, panelSlots: number[]) => void } ) { this._overrideStartLoad = opts?.onStartLoad ?? null; - this._versionStamp = TIMELINE_MODE.peek() ? SETTLED_COMMIT.peek() : LIVE_STAMP; + this._versionStamp = timeline?.mode.peek() ? timeline.settledCommit.peek() : LIVE_STAMP; this._capacity = mediaFileCapacity; // 4 faces per media building → total slot count. const slotCount = mediaFileCapacity * 4; @@ -392,7 +390,7 @@ export class InstancedFacadePanels { radius: Math.max(b.w, b.d, panelHeight), shown: true, loadedKey: null, - wantKey: versionKeyFor(b), + wantKey: versionKeyFor(b, this.timeline), startLoad, }); @@ -548,10 +546,10 @@ export class InstancedFacadePanels { /** Re-ask every panel which version it wants, only when the scrub moved: * versionKeyFor walks a path's history, so it stays off the per-frame path. */ private _refreshWantedVersions(): void { - const stamp = TIMELINE_MODE.peek() ? SETTLED_COMMIT.peek() : LIVE_STAMP; + const stamp = this.timeline?.mode.peek() ? this.timeline.settledCommit.peek() : LIVE_STAMP; if (stamp === this._versionStamp) return; this._versionStamp = stamp; - for (const rec of this._panels) rec.wantKey = versionKeyFor(rec.b); + for (const rec of this._panels) rec.wantKey = versionKeyFor(rec.b, this.timeline); } dispose(): void { @@ -601,14 +599,14 @@ function asyncLoadMediaForBuilding( if (!kind) return; // Absent at this commit: there is no blob, and asking by path would hit HEAD // and 404. The building just shows no image. - if (hasNoContentAtScrub(b.file.path)) return; + if (ads.timeline?.hasNoContentAtScrub(b.file.path)) return; const source = ads.source; if (!source) return; const filePath = b.file.path || ''; // Scrubbed commits pin a version; Live keys on mtime. Either way the URL names // one immutable body, so a rebuild re-reads it from the browser cache. - const sha = scrubbedBlobShaFor(b.file.path); + const sha = ads.timeline?.scrubbedBlobShaFor(b.file.path) ?? null; const version = b.file.modified || ''; if (kind === MediaKind.Image) { @@ -626,16 +624,17 @@ function asyncLoadDataFacadeForBuilding( layer: number, panelSlots: number[] ): void { - if (hasNoContentAtScrub(b.file.path)) return; + if (ads.timeline?.hasNoContentAtScrub(b.file.path)) return; const source = ads.source; if (!source) return; const filePath = b.file.path || ''; const version = b.file.modified || ''; + const sha = ads.timeline?.scrubbedBlobShaFor(b.file.path) ?? null; switch (dataFacadeKind(b.file.extension || '')) { case 'font': void _loadCanvasFacade( ads, - () => renderFontGlyphFacade(source, filePath, version), + () => renderFontGlyphFacade(source, filePath, version, sha), layer, panelSlots ); @@ -643,7 +642,7 @@ function asyncLoadDataFacadeForBuilding( case 'audio': void _loadCanvasFacade( ads, - () => renderWaveformFacade(source, filePath, version), + () => renderWaveformFacade(source, filePath, version, sha), layer, panelSlots ); diff --git a/app/src/city/components/buildings/fader.ts b/app/src/city/components/buildings/fader.ts index 9c61591b..760e4bbc 100644 --- a/app/src/city/components/buildings/fader.ts +++ b/app/src/city/components/buildings/fader.ts @@ -6,7 +6,7 @@ import * as THREE from 'three'; import { effect, untracked } from '@preact/signals'; import { BUILDINGS } from '@/state/settings/fields/buildings'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; +import type { TimelineStore } from '@/state/stores/timeline'; import { FadeDetail, NodeKind } from '@/types'; import { resolveDirTarget, tierFor } from './fadeTiers'; import { setBuildingsTranslucent } from './material'; @@ -26,15 +26,18 @@ export function createBuildingFader({ world, cityState, picker, + timeline, }: { world: FaderWorld; cityState: CityState; picker: ReturnType; + /** This city's history, when something scrubs it. */ + timeline: TimelineStore | null; }) { function _sweepAll(): void { // Timeline mode owns iFade per frame (scrub controller); a hover/select sweep // here would fight it. Live mode never takes this branch, so it stays identical. - if (TIMELINE_MODE.peek()) return; + if (timeline?.mode.peek()) return; const sel = picker.selection.value; const hov = picker.hover.value; diff --git a/app/src/city/components/buildings/index.ts b/app/src/city/components/buildings/index.ts index 6dc79f7d..ba7e6ce8 100644 --- a/app/src/city/components/buildings/index.ts +++ b/app/src/city/components/buildings/index.ts @@ -10,7 +10,6 @@ import { BUILDINGS, BUILDING_DIMENSIONS } from '@/state/settings/fields/building import { BLOOM } from '@/state/settings/fields/effects'; import { SCENE } from '@/state/settings/fields/scene'; import { RUINS } from '@/state/settings/fields/ruins'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; import type { Building, CityLayout, DateRanges, EnteringBuilding, StayingBuilding } from '@/types'; import type { FrameContext, SceneComponent, SceneContext } from '../../types'; @@ -67,13 +66,15 @@ export interface Buildings extends SceneComponent { * ad panels. The scrub pass decides the states; this owns the writes. */ applyScrub(states: ReadonlyMap): void; /** Install (or clear with null) the Timeline scrub controller, which drives - * scaleY + iFade per frame while TIMELINE_MODE is on. */ + * scaleY + iFade per frame while a scrub is on. */ setScrubController(controller: { update(): void } | null): void; /** Window-resize hook — forwards to the outline LineMaterial resolution. */ onResize(): void; } export function createBuildings(ctx: SceneContext): Buildings { + // This city's history, or null when nothing scrubs it. + const timeline = ctx.timeline?.store ?? null; // Persistent outer group — added to the scene once by createCity. rebuild() // swaps the inner cell root in and out of this group. const group = new THREE.Group(); @@ -162,6 +163,7 @@ export function createBuildings(ctx: SceneContext): Buildings { }, cityState: ctx.cityState, picker: ctx.picker!, + timeline, }); // Their overlays go straight on the scene: explicit renderOrders, so where // they sit in the graph doesn't decide draw order. @@ -215,7 +217,7 @@ export function createBuildings(ctx: SceneContext): Buildings { }); // Timeline scrub controller (installed by the timeline lifecycle). While - // TIMELINE_MODE, it drives scaleY + iFade per frame instead of the tweens. + // a scrub, it drives scaleY + iFade per frame instead of the tweens. let _scrubController: { update(): void } | null = null; // The prior meshes are disposed by now, but that frees GPU geometry only: the @@ -318,7 +320,7 @@ export function createBuildings(ctx: SceneContext): Buildings { function tick(_dt: number, frame: FrameContext): void { // First in the tick: outline and ghost read these matrices further down. In // Timeline the scrub controller owns them instead and this stays dormant. - if (TIMELINE_MODE.peek() && _scrubController) _scrubController.update(); + if (timeline?.mode.peek() && _scrubController) _scrubController.update(); else _tweens.update(0); _arm.arm(); _fader?.update(0); @@ -386,7 +388,8 @@ export function createBuildings(ctx: SceneContext): Buildings { const cellOut = buildCellsFromLayout( bounds, buildings, - sourceOf(ctx.cityState.manifest.peek()) + sourceOf(ctx.cityState.manifest.peek()), + timeline ); // Grabbed before the swap reassigns them: the arrays stay readable through @@ -431,7 +434,7 @@ export function createBuildings(ctx: SceneContext): Buildings { _scrubController = null; if (!_firstBuildDone) { _firstBuildDone = true; - } else if (!TIMELINE_MODE.peek()) { + } else if (!timeline?.mode.peek()) { // Timeline mode packs the union once; the scrub controller owns the // matrix from here, so don't animate a per-commit diff against it. _tweens.onDiff(diff); diff --git a/app/src/city/forProject.ts b/app/src/city/forProject.ts new file mode 100644 index 00000000..b64d7fd5 --- /dev/null +++ b/app/src/city/forProject.ts @@ -0,0 +1,29 @@ +// city/forProject.ts — the configuration that renders one project as a city: +// what it shows, whose readouts its build drives, what it is a picture of, and +// the scrubber that owns it in Timeline. The city layer reads no store, so this +// is the one place a session's signals become a city's config. + +import { computed } from '@preact/signals'; +import { CameraMode } from '@/city/render/cameraRig'; +import type { ProjectSession } from '@/state/project/session'; +import type { CityProps } from '@/city/City'; +import type { Manifest } from '@/types'; + +/** Render `session` as a city: pass the result straight to . */ +export function cityPropsFor(session: ProjectSession): CityProps { + const { manifest, source, progress, timeline } = session; + return { + // The store's value spans the skeleton the stream emits before it is fully + // typed; the city takes manifests. + source: computed(() => manifest.current.value as Manifest | null), + cameraMode: CameraMode.Project, + report: progress.reporter, + subjectKey: () => source.key.peek(), + timeline: { + store: timeline, + liveManifest: () => manifest.current.peek() as Manifest | null, + repack: () => session.timelineMode.reapply(), + }, + handle: session.city, + }; +} diff --git a/app/src/city/index.ts b/app/src/city/index.ts index 2e9897c3..46f2f69e 100644 --- a/app/src/city/index.ts +++ b/app/src/city/index.ts @@ -92,6 +92,7 @@ export async function createCity( scene, canvas, cityState, + timeline: timeline ?? null, picker: null, } as unknown as SceneContext; @@ -198,6 +199,7 @@ export async function createCity( canvas, camera: rig.camera, cityState, + timeline: timeline?.store ?? null, world: { getStreetPickables: () => streets.getPickables(), getRootGem: () => gem.getRootGroup(), @@ -219,6 +221,7 @@ export async function createCity( rig, renderer, cityState, + timeline: timeline?.store ?? null, showTooltip, hideTooltip, onResize() { @@ -269,8 +272,8 @@ export async function createCity( after() { // Drop a selection the scrub removed, but not mid-drag: closing the right // sidebar then reflows the track under the pointer and jumps the position. - if (!timeline?.mode.peek() || timeline.scrubDragging.peek()) return; - const pos = timeline.scrubPos.peek(); + if (!timeline?.store.mode.peek() || timeline.store.dragging.peek()) return; + const pos = timeline.store.scrubPos.peek(); if (pos === _lastPrunedScrubPos) return; _lastPrunedScrubPos = pos; picker.pruneScrubHiddenSelection(); @@ -284,6 +287,7 @@ export async function createCity( ): void { _scrubController?.dispose(); _scrubController = createScrubController({ + timeline: timeline!.store, buildings: { getBuildingIndex: () => buildings.getBuildingIndex(), applyScrub: (states) => buildings.applyScrub(states), @@ -322,7 +326,7 @@ export async function createCity( // HEAD, so only a rebuild from the live manifest is a valid live city. const stopTimelineTeardown = timeline ? effect(() => { - if (timeline.mode.value || !_scrubController) return; + if (timeline.store.mode.value || !_scrubController) return; timelineApi.uninstallScrubController(); const live = timeline.liveManifest(); // Best-effort: a dispose or a newer apply can supersede this mid-flight, @@ -334,7 +338,7 @@ export async function createCity( /** Re-pack what is already on screen: the settings path. A union city under * a scrubber was not built from one manifest, so it reassembles instead. */ async function repack(): Promise { - if (timeline?.mode.peek()) return timeline.repack(); + if (timeline?.store.mode.peek()) return timeline.repack(); const showing = cityState.manifest.peek(); if (showing) await applyManifest(showing); } diff --git a/app/src/city/interaction/inputHandlers.ts b/app/src/city/interaction/inputHandlers.ts index 9be2fad6..c5e1aec2 100644 --- a/app/src/city/interaction/inputHandlers.ts +++ b/app/src/city/interaction/inputHandlers.ts @@ -11,10 +11,15 @@ const TOUCH_CLICK_MOVE_THRESHOLD_PX = 12; const TOUCH_CLICK_TIME_THRESHOLD_MS = 700; const INPUT_HOVER_COMMIT_MS = 35; import { KEY_BINDINGS, TEXT_INPUT_TAGS } from '@/constants/keyboard'; -import { OVERLAY_OPEN, openSelectionPane } from '@/state/stores/chrome'; -import { focusSelection } from '@/city/sceneHandle'; +import { + OVERLAY_OPEN, + openSelectionPane, + dismissSelectionPane, + SIDEBAR_COLLAPSED, +} from '@/state/stores/chrome'; +import { IS_PHONE } from '@/state/stores/viewport'; import { NodeKind } from '@/types'; -import { scrubbedStatsFor } from '@/state/stores/timeline'; +import type { TimelineStore } from '@/state/stores/timeline'; import type { PickTarget } from '@/types'; import { hoverTooltipContent, type TooltipContent } from './tooltipText'; import type { createPicker } from './picker'; @@ -27,6 +32,7 @@ export function createInputHandlers({ rig, renderer, cityState, + timeline, showTooltip, hideTooltip, onResize, @@ -37,6 +43,8 @@ export function createInputHandlers({ rig: ReturnType; renderer: THREE.WebGLRenderer; cityState: CityState; + /** This city's history, for the hover readout at a scrubbed commit. */ + timeline: TimelineStore | null; showTooltip: (content: TooltipContent, x: number, y: number) => void; hideTooltip: () => void; onResize: () => void; @@ -94,9 +102,9 @@ export function createInputHandlers({ const rootName = cityState.manifest.peek()?.tree?.name ?? null; const scrubLines = newHover?.kind === NodeKind.File && newHover.file?.path != null - ? (scrubbedStatsFor(newHover.file.path)?.lines ?? null) + ? (timeline?.scrubbedStatsFor(newHover.file.path)?.lines ?? null) : null; - const tooltipText = hoverTooltipContent(newHover, rootName, scrubLines); + const tooltipText = hoverTooltipContent(newHover, rootName, scrubLines, timeline); if (tooltipText) { showTooltip(tooltipText, e.clientX, e.clientY); canvas.style.cursor = 'pointer'; @@ -220,7 +228,14 @@ export function createInputHandlers({ } else if (KEY_BINDINGS.FOCUS_SELECTION.keys.includes(ev.key)) { // The command the panes' Focus buttons call. The gem isn't selectable: // clicking it resets the view instead. - focusSelection(); + // Aim at whatever is selected, and get the chrome out of the way: the + // same thing the panes' Focus button does, from this city's own picker. + const sel = picker.selection.peek(); + if (sel) { + rig.focusSelection(sel); + dismissSelectionPane(); + if (IS_PHONE.peek()) SIDEBAR_COLLAPSED.value = true; + } } }); diff --git a/app/src/city/interaction/picker.ts b/app/src/city/interaction/picker.ts index 9bffb17d..7cdfd317 100644 --- a/app/src/city/interaction/picker.ts +++ b/app/src/city/interaction/picker.ts @@ -1,36 +1,38 @@ // city/interaction/picker.ts — owns the raycaster + the hover/selection state -// machine on @preact/signals. selection is the source of truth; the in-memory -// PICKER_SELECTION_KEY (path/sha) is derived from it and re-resolved to a live -// target on world rebuilds, so selections survive mesh swaps. Not persisted. +// machine on @preact/signals, per city. selection is the source of truth; the +// in-memory selectionKey (path/sha) is derived from it and re-resolved to a +// live target on world rebuilds, so selections survive mesh swaps. import * as THREE from 'three'; import { ObjectBVH } from 'three-mesh-bvh'; import { signal, effect, untracked } from '@preact/signals'; import { NodeKind } from '@/types'; import { sidewalkStreetForFace } from '@/city/components/streets/streets'; import { BuildingKind } from '@/city/components/buildings/buildingKind'; -import { TIMELINE_MODE, SCRUB_POS, TIMELINE_BUNDLE } from '@/state/stores/timeline'; +import type { TimelineStore } from '@/state/stores/timeline'; import { RUINED_STREET_DIRS } from '@/city/components/streets/scrubState'; import type { CommitEntry, PickTarget, PickerWorld, PickerSelectionKey } from '@/types'; import type { CityState } from '@/city/state'; -// In-memory selection key. Reset to null on a fresh load; survives in-session -// world rebuilds via the re-resolution below. Never written to localStorage. -export const PICKER_SELECTION_KEY = signal(null); - export function createPicker({ canvas, camera, world, cityState, + timeline, }: { canvas: HTMLCanvasElement; camera: THREE.Camera; world: PickerWorld; cityState: CityState; + /** This city's history, when something scrubs it. */ + timeline: TimelineStore | null; }) { const hover = signal(null); const selection = signal(null); + // In-memory selection key, per city. Reset to null on a fresh load; survives + // in-session world rebuilds via the re-resolution below. Never persisted. + const selectionKey = signal(null); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); @@ -77,19 +79,19 @@ export function createPicker({ const sel = selection.value; if (_suspendKeyDerive) return; if (!sel) { - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; return; } if (sel.kind === NodeKind.File && sel.file?.path != null) { - PICKER_SELECTION_KEY.value = { kind: NodeKind.File, path: sel.file.path }; + selectionKey.value = { kind: NodeKind.File, path: sel.file.path }; return; } if (sel.kind === NodeKind.Directory && sel.dir?.path != null) { - PICKER_SELECTION_KEY.value = { kind: NodeKind.Directory, path: sel.dir.path }; + selectionKey.value = { kind: NodeKind.Directory, path: sel.dir.path }; return; } if (sel.kind === NodeKind.Commit && sel.commit?.sha) { - PICKER_SELECTION_KEY.value = { kind: NodeKind.Commit, sha: sel.commit.sha }; + selectionKey.value = { kind: NodeKind.Commit, sha: sel.commit.sha }; return; } }); @@ -99,7 +101,7 @@ export function createPicker({ // Key → selection re-resolution on rebuild: a manifest swap stales every // live mesh ref; the key keeps the selected node alive. function _resolveKeyToSelection() { - const key = PICKER_SELECTION_KEY.value; + const key = selectionKey.value; _refreshPickables(); // also refresh pickables on every rebuild if (!key) { @@ -122,7 +124,7 @@ export function createPicker({ }; } else { selection.value = null; - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; } _suspendKeyDerive = false; return; @@ -140,7 +142,7 @@ export function createPicker({ }; } else { selection.value = null; - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; } _suspendKeyDerive = false; return; @@ -154,7 +156,7 @@ export function createPicker({ selection.value = target; } else { selection.value = null; - PICKER_SELECTION_KEY.value = null; + selectionKey.value = null; } _suspendKeyDerive = false; return; @@ -180,10 +182,10 @@ export function createPicker({ // here are the ones the components just built. Fires once at construction too. // The scrub rewrites building matrices per frame but the BVH caches bounds - // at build time — invalidate on SCRUB_POS or hitboxes freeze mid-scrub. + // at build time — invalidate on the scrub position or hitboxes freeze. const _disposeScrubBvhEffect = effect(() => { - void SCRUB_POS.value; - if (!TIMELINE_MODE.peek()) return; + void timeline?.scrubPos.value; + if (!timeline?.mode.peek()) return; _bvh = null; _bvhDirty = true; }); @@ -237,7 +239,7 @@ export function createPicker({ /** The commit itself, for one the city drew no tree for. Timeline's list is * the one the scrubber names; Live's comes off the manifest. */ function _commitBySha(sha: string): CommitEntry | null { - const commits = TIMELINE_BUNDLE.peek()?.commits ?? cityState.manifest.peek()?.commits ?? []; + const commits = timeline?.bundle.peek()?.commits ?? cityState.manifest.peek()?.commits ?? []; return commits.find((c) => c.sha === sha) ?? null; } @@ -386,7 +388,7 @@ export function createPicker({ slot: number | undefined ): PickTarget | null { if (slot == null) return null; - if (TIMELINE_MODE.peek() && _buildingScrubHidden(mesh, slot)) return null; + if (timeline?.mode.peek() && _buildingScrubHidden(mesh, slot)) return null; const building = world.getBuildingIndex()?.byCellSlot(`${cellId}:${slot}`); if (!building?.file) return null; return { @@ -395,7 +397,7 @@ export function createPicker({ data: building, file: building.file, instanceId: slot, - isRuin: TIMELINE_MODE.peek() && _buildingIsRuin(mesh, slot), + isRuin: !!timeline?.mode.peek() && _buildingIsRuin(mesh, slot), }; } @@ -403,7 +405,7 @@ export function createPicker({ * its street through the faceIndex map baked onto userData. */ function sidewalkTargetFor(hit: THREE.Intersection): PickTarget | null { const mesh = hit.object as THREE.Mesh; - if (TIMELINE_MODE.peek() && _streetScrubHidden(mesh, hit.face?.a)) return null; + if (timeline?.mode.peek() && _streetScrubHidden(mesh, hit.face?.a)) return null; const street = sidewalkStreetForFace(hit.object, hit.faceIndex ?? 0); if (!street?.dir) return null; return { @@ -411,7 +413,7 @@ export function createPicker({ sidewalk: mesh, street, dir: street.dir, - isRuin: TIMELINE_MODE.peek() && RUINED_STREET_DIRS.has(street.dir.path), + isRuin: !!timeline?.mode.peek() && RUINED_STREET_DIRS.has(street.dir.path), vertexHint: hit.face?.a, }; } @@ -456,7 +458,7 @@ export function createPicker({ return { hover, selection, - selectionKey: PICKER_SELECTION_KEY, + selectionKey, setHover, setSelection, clearSelection, diff --git a/app/src/city/interaction/tooltipText.ts b/app/src/city/interaction/tooltipText.ts index 3562688d..4cffe025 100644 --- a/app/src/city/interaction/tooltipText.ts +++ b/app/src/city/interaction/tooltipText.ts @@ -7,6 +7,7 @@ import type { PickTarget } from '@/types'; import { formatRelativeAge } from '@/utils/dates'; import { ROOT_PATH } from '@/constants/manifest'; import { fileStatItems, directoryStatItems } from '@/components/panes/PaneStats/statItems'; +import type { TimelineStore } from '@/state/stores/timeline'; /** Longest path rendered before the middle segments collapse to an ellipsis. */ const PATH_BUDGET_CHARS = 44; @@ -57,8 +58,10 @@ export function middleTruncatePath(path: string, budget = PATH_BUDGET_CHARS): st export function hoverTooltipContent( target: PickTarget | null, rootName: string | null, - // Timeline: lines at the scrubbed commit, or at deletion for a file already gone. - scrubLines?: number | null + // Timeline: lines at the scrubbed commit, or at deletion for a file already + // gone, and the history those come from. + scrubLines?: number | null, + timeline?: TimelineStore | null ): TooltipContent | null { if (!target) return null; const deleted = isDeletedTarget(target); @@ -88,7 +91,7 @@ export function hoverTooltipContent( return { title: f.name || rel, path: middleTruncatePath(withRoot(parent, rootName)), - stats: fileStatItems(node, { dates: false }).map((i) => i.text), + stats: fileStatItems(node, { dates: false, timeline }).map((i) => i.text), deleted, }; } diff --git a/app/src/city/openedProject.ts b/app/src/city/openedProject.ts deleted file mode 100644 index 601c03d4..00000000 --- a/app/src/city/openedProject.ts +++ /dev/null @@ -1,34 +0,0 @@ -// city/openedProject.ts — the configuration that makes a city BE the project -// you opened: what it shows, whose readouts it drives, what it is a picture -// of, and the scrubber that owns it in Timeline. The city layer reads no -// store, so this is the one file naming those signals. - -import { computed } from '@preact/signals'; -import { MANIFEST } from '@/state/stores/manifest'; -import { CURRENT_SOURCE_KEY } from '@/state/stores/source'; -import { OPENED_PROJECT_REPORTER } from '@/state/stores/progress'; -import { TIMELINE_MODE, SCRUB_POS, SCRUB_DRAGGING } from '@/state/stores/timeline'; -import { SCENE_HANDLE } from '@/city/sceneHandle'; -import { reapplyTimelineScene } from '@/hooks/useTimelineMode'; -import { CameraMode } from '@/city/render/cameraRig'; -import type { CityProps } from '@/city/City'; -import type { Manifest } from '@/types'; - -/** MANIFEST, as the city layer takes it: the store's value spans the skeleton - * the stream emits before it is fully typed. */ -const OPENED_MANIFEST = computed(() => MANIFEST.value as Manifest | null); - -export const OPENED_PROJECT: CityProps = { - source: OPENED_MANIFEST, - cameraMode: CameraMode.Project, - report: OPENED_PROJECT_REPORTER, - subjectKey: () => CURRENT_SOURCE_KEY.peek(), - timeline: { - mode: TIMELINE_MODE, - scrubPos: SCRUB_POS, - scrubDragging: SCRUB_DRAGGING, - liveManifest: () => MANIFEST.peek() as Manifest | null, - repack: reapplyTimelineScene, - }, - handle: SCENE_HANDLE, -}; diff --git a/app/src/city/sceneHandle.ts b/app/src/city/sceneHandle.ts index 1c154e56..fb189a4f 100644 --- a/app/src/city/sceneHandle.ts +++ b/app/src/city/sceneHandle.ts @@ -1,29 +1,27 @@ -// city/sceneHandle.ts — the live scene handle, and the commands the UI sends -// through it. Not app state: it holds a Three.js object and calls methods on -// it, so it lives with the renderer rather than in state/stores. +// city/sceneHandle.ts — commanding one project's city: waiting for it to +// exist, and the verbs the chrome sends it. Not app state — it holds a +// Three.js object and calls methods on it — and not global: every command +// works on the session it was made for. -import { signal, effect } from '@preact/signals'; -import type { createCity } from './index'; +import { effect, type Signal } from '@preact/signals'; +import type { City } from './types'; import type { FocusMode } from './render/cameraRig'; +import type { ProjectSession } from '@/state/project/session'; import { SIDEBAR_COLLAPSED, dismissSelectionPane, openSelectionPane } from '@/state/stores/chrome'; import { IS_PHONE } from '@/state/stores/viewport'; -export type SceneHandle = Awaited>; +export type SceneHandle = City; -/** The city the app's chrome drives: sidebars, selection chip, capture. One - * published nowhere (the landing's wallpaper) is nobody's to command. */ -export const SCENE_HANDLE = signal(null); - -/** Resolves once the city exists. A boot load can outrun createCity, and a load - * that finds no handle has nowhere to put its city. */ -export function whenSceneHandle(): Promise { - const ready = SCENE_HANDLE.peek(); +/** Resolves once this project's city exists. A boot load can outrun createCity, + * and a load that finds no city has nowhere to put its manifest. */ +export function whenCity(city: Signal): Promise { + const ready = city.peek(); if (ready) return Promise.resolve(ready); return new Promise((resolve) => { const stop = effect(() => { - const handle = SCENE_HANDLE.value; - if (!handle) return; - resolve(handle); + const made = city.value; + if (!made) return; + resolve(made); queueMicrotask(() => stop()); }); }); @@ -49,97 +47,135 @@ function revealDetails(): void { collapseDrawerOnPhone(); } -/** What a command points the camera at: a node by path, a commit by sha, or - * whatever is already selected. */ -type NodeRef = { path: string } | { sha: string } | null; - -/** Select what `ref` names and aim the camera at it: the one place a ref becomes - * a focus. False when there is nothing to look at, so the chrome stays put. */ -function _pointAt(ref: NodeRef, mode?: FocusMode): boolean { - const handle = SCENE_HANDLE.peek(); - if (!handle) return false; - const sel = - ref === null - ? handle.picker.selection.peek() - : 'sha' in ref - ? handle.picker.selectByCommit(ref.sha) - : handle.picker.selectByPath(ref.path); - if (!sel) return false; - handle.rig.focusSelection(sel, mode); - return true; -} - -// Thin wrappers the UI calls instead of reaching into the handle itself. All -// no-op before the scene boots. - -/** Hover-highlight the node at `path` (tree-row hover → city highlight). */ -export function hoverPath(path: string): void { - SCENE_HANDLE.peek()?.picker.hoverByPath(path); -} - -/** Clear the hover highlight. */ -export function clearHover(): void { - SCENE_HANDLE.peek()?.picker.setHover(null); -} - -/** Clear the current selection (closes the contextual right sidebar). */ -export function clearSelection(): void { - SCENE_HANDLE.peek()?.picker.clearSelection(); -} - -/** Focus a node, selecting it first if it isn't: an almanac row is a Focus - * button for something you haven't picked yet. Re-selecting is identity. */ -export function focusPath(path: string, mode?: FocusMode): void { - if (_pointAt({ path }, mode)) revealCity(); -} - -/** focusPath for a commit's tree, by sha. */ -export function focusCommit(sha: string, mode?: FocusMode): void { - if (_pointAt({ sha }, mode)) revealCity(); -} - -/** Focus whatever is selected, whichever kind. Here rather than in the key - * handler: a keystroke and a Focus button are the same request. */ -export function focusSelection(mode?: FocusMode): void { - if (_pointAt(null, mode)) revealCity(); -} - -/** Go to a node named in a list. The details open, unlike the Focus commands: - * there you act on what's in front of you, here you asked for the name. */ -export function goToPath(path: string, mode?: FocusMode): void { - if (_pointAt({ path }, mode)) revealDetails(); -} - -/** goToPath for a commit's tree, by sha (almanac landmarks). */ -export function goToCommit(sha: string, mode?: FocusMode): void { - if (_pointAt({ sha }, mode)) revealDetails(); -} - -/** A commit's details, with the camera left alone: the timeline's own row, - * where you are already looking at what you asked about. */ -export function showCommit(sha: string): void { - const handle = SCENE_HANDLE.peek(); - if (!handle) return; - handle.picker.selectByCommit(sha); - openSelectionPane(); -} - -/** Reset the camera framing to the current mode's default pose. */ -export function resetView(): void { - SCENE_HANDLE.peek()?.rig.reset(); -} - -/** Debug: run the building/street collision check. */ -export function runCollisionCheck(): void { - SCENE_HANDLE.peek()?.world.runCollisionCheck(); -} - -/** Debug: run the stem-placement diagnostic. */ -export function runStemDiagnostic(): void { - SCENE_HANDLE.peek()?.world.runStemPlacementDiagnostic(); -} - -/** Debug: audit every tree's contact with the ground. */ -export function runTreeGroundingCheck(): void { - SCENE_HANDLE.peek()?.world.runTreeGroundingDiagnostic(); +/** The verbs the chrome sends one project's city. All no-op before its scene + * boots, and none of them can reach another session's. */ +export interface CityCommands { + hoverPath(path: string): void; + clearHover(): void; + clearSelection(): void; + focusPath(path: string, mode?: FocusMode): void; + focusCommit(sha: string, mode?: FocusMode): void; + focusSelection(mode?: FocusMode): void; + goToPath(path: string, mode?: FocusMode): void; + goToCommit(sha: string, mode?: FocusMode): void; + showCommit(sha: string): void; + resetView(): void; + runCollisionCheck(): void; + runStemDiagnostic(): void; + runTreeGroundingCheck(): void; +} + +export function createCityCommands(session: ProjectSession): CityCommands { + const city = session.city; + + /** What a command points the camera at: a node by path, a commit by sha, or + * whatever is already selected. */ + type NodeRef = { path: string } | { sha: string } | null; + + /** Select what `ref` names and aim the camera at it: the one place a ref becomes + * a focus. False when there is nothing to look at, so the chrome stays put. */ + function _pointAt(ref: NodeRef, mode?: FocusMode): boolean { + const handle = city.peek(); + if (!handle) return false; + const sel = + ref === null + ? handle.picker.selection.peek() + : 'sha' in ref + ? handle.picker.selectByCommit(ref.sha) + : handle.picker.selectByPath(ref.path); + if (!sel) return false; + handle.rig.focusSelection(sel, mode); + return true; + } + + // Thin wrappers the UI calls instead of reaching into the handle itself. All + // no-op before the scene boots. + + /** Hover-highlight the node at `path` (tree-row hover → city highlight). */ + function hoverPath(path: string): void { + city.peek()?.picker.hoverByPath(path); + } + + /** Clear the hover highlight. */ + function clearHover(): void { + city.peek()?.picker.setHover(null); + } + + /** Clear the current selection (closes the contextual right sidebar). */ + function clearSelection(): void { + city.peek()?.picker.clearSelection(); + } + + /** Focus a node, selecting it first if it isn't: an almanac row is a Focus + * button for something you haven't picked yet. Re-selecting is identity. */ + function focusPath(path: string, mode?: FocusMode): void { + if (_pointAt({ path }, mode)) revealCity(); + } + + /** focusPath for a commit's tree, by sha. */ + function focusCommit(sha: string, mode?: FocusMode): void { + if (_pointAt({ sha }, mode)) revealCity(); + } + + /** Focus whatever is selected, whichever kind. Here rather than in the key + * handler: a keystroke and a Focus button are the same request. */ + function focusSelection(mode?: FocusMode): void { + if (_pointAt(null, mode)) revealCity(); + } + + /** Go to a node named in a list. The details open, unlike the Focus commands: + * there you act on what's in front of you, here you asked for the name. */ + function goToPath(path: string, mode?: FocusMode): void { + if (_pointAt({ path }, mode)) revealDetails(); + } + + /** goToPath for a commit's tree, by sha (almanac landmarks). */ + function goToCommit(sha: string, mode?: FocusMode): void { + if (_pointAt({ sha }, mode)) revealDetails(); + } + + /** A commit's details, with the camera left alone: the timeline's own row, + * where you are already looking at what you asked about. */ + function showCommit(sha: string): void { + const handle = city.peek(); + if (!handle) return; + handle.picker.selectByCommit(sha); + openSelectionPane(); + } + + /** Reset the camera framing to the current mode's default pose. */ + function resetView(): void { + city.peek()?.rig.reset(); + } + + /** Debug: run the building/street collision check. */ + function runCollisionCheck(): void { + city.peek()?.world.runCollisionCheck(); + } + + /** Debug: run the stem-placement diagnostic. */ + function runStemDiagnostic(): void { + city.peek()?.world.runStemPlacementDiagnostic(); + } + + /** Debug: audit every tree's contact with the ground. */ + function runTreeGroundingCheck(): void { + city.peek()?.world.runTreeGroundingDiagnostic(); + } + + return { + hoverPath, + clearHover, + clearSelection, + focusPath, + focusCommit, + focusSelection, + goToPath, + goToCommit, + showCommit, + resetView, + runCollisionCheck, + runStemDiagnostic, + runTreeGroundingCheck, + }; } diff --git a/app/src/city/timeline/scrubController.ts b/app/src/city/timeline/scrubController.ts index 1801b834..d617ea07 100644 --- a/app/src/city/timeline/scrubController.ts +++ b/app/src/city/timeline/scrubController.ts @@ -1,7 +1,7 @@ // Timeline mode's per-frame driver: read the frame, run the pass, hand each // component its slice. It writes nobody else's buffers. -import { TIMELINE_BUNDLE, SCRUB_TODAY_MS } from '@/state/stores/timeline'; +import type { TimelineStore } from '@/state/stores/timeline'; import type { RangeStat, Street } from '@/types'; import type { BuildingIndex } from '@/city/components/buildings/buildingIndex'; import type { BuildingScrubState } from '@/city/components/buildings/scrubState'; @@ -23,6 +23,8 @@ export interface ScrubGate { } export interface ScrubControllerDeps { + /** The history being scrubbed: this city's, never the app's. */ + timeline: TimelineStore; buildings: { /** The union set the pass decides over. */ getBuildingIndex(): BuildingIndex | null; @@ -46,12 +48,12 @@ export interface ScrubControllerDeps { export function createScrubController(deps: ScrubControllerDeps) { // Fixed for the life of the controller; readScrubFrame owns everything that // varies per frame. - const bundle = TIMELINE_BUNDLE.peek(); + const bundle = deps.timeline.bundle.peek(); const commitMs = (bundle?.commits ?? []).map((c) => parseDateMs(c.date) || 0); const commitDateRanges = bundle?.commitDateRanges ?? []; const scannedAtMs = parseDateMs(deps.scannedAt ?? '') || (commitMs.at(-1) ?? 0); // The same last stop the bar ends on, so the two agree about the far end. - const trackEndMs = SCRUB_TODAY_MS.peek() ?? scannedAtMs; + const trackEndMs = deps.timeline.todayMs.peek() ?? scannedAtMs; const pass = createScrubPass({ buildingIndex: deps.buildings.getBuildingIndex(), @@ -62,6 +64,7 @@ export function createScrubController(deps: ScrubControllerDeps) { function update(): void { const frame = readScrubFrame({ + scrubPos: deps.timeline.scrubPos.peek(), commitLineRanges: deps.commitLineRanges, commitDateRanges, commitMs, diff --git a/app/src/city/timeline/scrubFrame.ts b/app/src/city/timeline/scrubFrame.ts index f7e36838..0dab47f5 100644 --- a/app/src/city/timeline/scrubFrame.ts +++ b/app/src/city/timeline/scrubFrame.ts @@ -1,7 +1,6 @@ -// The only per-frame reader of SCRUB_POS, the ruin/building stores +// The only per-frame reader of the scrub position, the ruin/building stores // and the picker. Everything downstream takes a ScrubFrame value. -import { SCRUB_POS } from '@/state/stores/timeline'; import { BUILDING_DIMENSIONS, BUILDINGS } from '@/state/settings/fields/buildings'; import { RUINS } from '@/state/settings/fields/ruins'; import { NodeKind } from '@/types'; @@ -40,6 +39,8 @@ export interface ScrubFrame { } export interface ScrubFrameDeps { + /** Where this city's scrubber is, read by its caller once per frame. */ + scrubPos: number; commitLineRanges: readonly RangeStat[]; /** Replayed over the present set, so at HEAD weathering matches Live. */ commitDateRanges: readonly CommitDateRange[]; @@ -63,9 +64,9 @@ function scrubNow(pos: number, deps: ScrubFrameDeps): number { } export function readScrubFrame(deps: ScrubFrameDeps): ScrubFrame { - const pos = SCRUB_POS.peek(); + const pos = deps.scrubPos; - // SCRUB_POS is clamped to the bundle; only the range arrays can fall short. + // The position is clamped to the bundle; only the ranges can fall short. const r = deps.commitLineRanges[Math.min(Math.floor(pos), deps.commitLineRanges.length - 1)]; const floorHeight = BUILDING_DIMENSIONS.peek().FLOOR_HEIGHT; diff --git a/app/src/city/types/index.ts b/app/src/city/types/index.ts index 5f0f7e05..b6bb464c 100644 --- a/app/src/city/types/index.ts +++ b/app/src/city/types/index.ts @@ -11,6 +11,7 @@ import type { ReadonlySignal } from '@preact/signals'; import type { Manifest, RangeStat } from '@/types'; import type { BuildStage } from '@/constants/progress'; import type { BuildReporter } from '@/state/stores/progress'; +import type { TimelineStore } from '@/state/stores/timeline'; /** What a component needs to wire itself in. picker is null until after the * components exist, so anything needing it arms on the first tick. */ @@ -19,6 +20,9 @@ export interface SceneContext { canvas: HTMLCanvasElement; picker: Picker; cityState: CityState; + /** This city's timeline, for the components drawing what a scrub implies. + * Null when nothing scrubs this one. */ + timeline: CityTimelineBinding | null; } /** Per-frame state passed to each component's tick() method. */ @@ -67,11 +71,8 @@ export interface CityTimeline { /** Scrubbing, for a city something scrubs through time. Left out, this city * has no timeline: nothing gates its contents and nothing prunes its picks. */ export interface CityTimelineBinding { - /** Tracked: leaving the mode tears this city's scrub controller down. */ - mode: ReadonlySignal; - /** Peeked per frame: where the scrubber is, and whether it is still held. */ - scrubPos: ReadonlySignal; - scrubDragging: ReadonlySignal; + /** The project's history state. Its own, so a second city scrubs its own. */ + store: TimelineStore; /** What to rebuild from on exit: a union city holds buildings that do not * exist at HEAD. A call, not a signal, so no store's type leaks in here. */ liveManifest(): Manifest | null; diff --git a/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx b/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx index 1e4604c0..bab35cf1 100644 --- a/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx +++ b/app/src/components/loading/LoadingOverlay/LoadingOverlay.tsx @@ -1,10 +1,10 @@ // components/loading/LoadingOverlay/LoadingOverlay.tsx — full-viewport centered -// progress, mounted by CityView and driven entirely by the LOADING_OVERLAY -// signal: what raises it and what takes it down is the store's call (see +// progress, mounted by CityView and driven entirely by its project's overlay +// state: what raises it and what takes it down is the store's call (see // attachLoadReaction), so this renders and nothing more. import './LoadingOverlay.css'; -import { LOADING_OVERLAY } from '@/state/stores/progress'; +import { useProject } from '@/state/project/context'; import { LoadingProgress } from '@/components/loading/LoadingProgress/LoadingProgress'; export interface LoadingOverlayProps { @@ -13,7 +13,8 @@ export interface LoadingOverlayProps { } export function LoadingOverlay({ onCancel }: LoadingOverlayProps) { - const lo = LOADING_OVERLAY.value; + const { progress } = useProject(); + const lo = progress.overlay.value; if (!lo.visible || !lo.activeStep) return null; return ( diff --git a/app/src/components/loading/LoadingProgress/LoadingProgress.tsx b/app/src/components/loading/LoadingProgress/LoadingProgress.tsx index ec9fbcf5..1dd48ae3 100644 --- a/app/src/components/loading/LoadingProgress/LoadingProgress.tsx +++ b/app/src/components/loading/LoadingProgress/LoadingProgress.tsx @@ -3,6 +3,7 @@ // which rows to draw and which one is live; what those mean is the store's. import './LoadingProgress.css'; +import { useProject } from '@/state/project/context'; import { BranchPill } from '@/components/sources/BranchPill/BranchPill'; import { SourceKind } from '@/utils/sources'; @@ -13,7 +14,6 @@ import { LOADING_STEP_LABELS, stepRuns, } from '@/constants/progress'; -import { PENDING_SOURCE_LABEL } from '@/state/stores/progress'; export interface LoadingProgressProps { activeStep: LoadingStep; @@ -35,7 +35,8 @@ export function LoadingProgress({ steps = LOADING_STEPS, onCancel, }: LoadingProgressProps) { - const pendingLabel = PENDING_SOURCE_LABEL.value; + const { progress } = useProject(); + const pendingLabel = progress.pendingLabel.value; const activeIdx = steps.indexOf(activeStep); return ( diff --git a/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx b/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx index 68732b18..0fa41340 100644 --- a/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx +++ b/app/src/components/menus/ImportExportMenu/ImportExportMenu.tsx @@ -4,17 +4,18 @@ // under what name, is the app's call rather than this panel's. import './ImportExportMenu.css'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useEffect, useRef, useState, useMemo } from 'preact/hooks'; import { useSignal } from '@preact/signals'; import { ArrowDownUp, FolderInput, Download } from 'lucide-preact'; import { Popover, PopoverPlacement } from '@/components/menus/Popover/Popover'; +import { useProject } from '@/state/project/context'; import { buildSettingsFile, parseSettingsFile, applySettingsFile, excludesOrigin, storePart, - EXCLUDES_PART, + excludesPart, SettingsFileError, TransferFamily, type ImportReport, @@ -69,12 +70,12 @@ function groupRow(group: TransferGroup, parts: TransferPart[]): TransferRow { return { id: `group:${group.key}`, label: group.label, family: group.family, parts }; } -function excludesRow(note: string): TransferRow { +function excludesRow(part: TransferPart, note: string): TransferRow { return { id: EXCLUDES_ROW, label: 'Excluded from City', - family: EXCLUDES_PART.family, - parts: [EXCLUDES_PART], + family: part.family, + parts: [part], note, }; } @@ -85,26 +86,31 @@ function partsOf(group: TransferGroup): TransferPart[] { /** Every part this build will accept from a file. Nothing outside it can be * applied, however a hand-edited file names it. */ -function catalogueOf(groups: readonly TransferGroup[]): TransferPart[] { - return [...groups.flatMap(partsOf), EXCLUDES_PART]; +function catalogueOf(groups: readonly TransferGroup[], excludes: TransferPart): TransferPart[] { + return [...groups.flatMap(partsOf), excludes]; } /** Everything this browser could send: every group, plus what the open project * hides. Always offered, since "I hide nothing" is a thing worth sending. */ -function exportRows(groups: readonly TransferGroup[], note: string): TransferRow[] { - return [...groups.map((g) => groupRow(g, partsOf(g))), excludesRow(note)]; +function exportRows( + groups: readonly TransferGroup[], + excludes: TransferPart, + note: string +): TransferRow[] { + return [...groups.map((g) => groupRow(g, partsOf(g))), excludesRow(excludes, note)]; } /** Only what the file actually carries, so an import never offers to reset a * section its author chose not to send. */ function importRows( groups: readonly TransferGroup[], + excludes: TransferPart, parsed: ParsedSettingsFile, note: string ): TransferRow[] { const carried = new Set(parsed.parts.map((p) => `${p.family}/${p.key}`)); const held = (part: TransferPart) => carried.has(`${part.family}/${part.key}`); - return exportRows(groups, note) + return exportRows(groups, excludes, note) .map((row) => ({ ...row, parts: row.parts.filter(held) })) .filter((row) => row.parts.length > 0); } @@ -145,6 +151,10 @@ export interface ImportExportMenuProps { } export function ImportExportMenu({ groups }: ImportExportMenuProps) { + const { source } = useProject(); + // This project's hidden paths: whose list travels is a question about which + // repo is open, so the part is made here rather than imported ready-made. + const excludes = useMemo(() => excludesPart(source), [source]); const open = useSignal(false); const [mode, setMode] = useState(Mode.Export); const [parsed, setParsed] = useState(null); @@ -174,8 +184,8 @@ export function ImportExportMenu({ groups }: ImportExportMenuProps) { const dotTitle = mode === Mode.Import ? DOT_TITLE.import : DOT_TITLE.export; const rows = mode === Mode.Import && parsed - ? importRows(groups, parsed, scanNote) - : exportRows(groups, scanNote); + ? importRows(groups, excludes, parsed, scanNote) + : exportRows(groups, excludes, scanNote); const chosen = rows.filter((r) => !off.has(r.id)); const toggleRow = (id: string, on: boolean) => { @@ -202,7 +212,7 @@ export function ImportExportMenu({ groups }: ImportExportMenuProps) { try { // The catalogue is the authority on what may travel: a hand-edited file // naming something outside it (auto-refresh, say) resolves to nothing. - setParsed(parseSettingsFile(await file.text(), catalogueOf(groups))); + setParsed(parseSettingsFile(await file.text(), catalogueOf(groups, excludes))); setOff(new Set()); setMode(Mode.Import); } catch (e) { diff --git a/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx b/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx index ff368bfb..0cfcb2ba 100644 --- a/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx +++ b/app/src/components/menus/ScanMenu/FreshnessStatus/FreshnessStatus.tsx @@ -7,14 +7,9 @@ import './FreshnessStatus.css'; import { useSignal } from '@preact/signals'; import { useEffect } from 'preact/hooks'; import { formatRelativeAgeShort } from '@/utils/dates'; -import { LIVE_UPDATES_ACTIVE } from '@/state/settings/fields/updates'; -import { - REBUILD_STATUS, - RebuildStatus, - LAST_REBUILD_ERROR, - LAST_UPDATED_AT, - REBUILD_DETAIL, -} from '@/state/stores/progress'; +import { liveUpdatesActive } from '@/state/settings/fields/updates'; +import { RebuildStatus } from '@/state/stores/progress'; +import { useProject } from '@/state/project/context'; // CSS modifier classes for the combined dot/detail (see FreshnessStatus.css). // Named so the className composition reads without inline magic strings. @@ -63,11 +58,12 @@ export function useFreshness(): Freshness { }, []); void tick.value; - const liveEnabled = LIVE_UPDATES_ACTIVE.value; - const rebuildStatus = REBUILD_STATUS.value; - const lastUpdatedAt = LAST_UPDATED_AT.value; - const errorMessage = LAST_REBUILD_ERROR.value; - const rebuildDetail = REBUILD_DETAIL.value; + const { progress, source } = useProject(); + const liveEnabled = liveUpdatesActive(source); + const rebuildStatus = progress.rebuildStatus.value; + const lastUpdatedAt = progress.lastUpdatedAt.value; + const errorMessage = progress.lastError.value; + const rebuildDetail = progress.detail.value; let buildClass: BuildClass; let detailText: string; diff --git a/app/src/components/menus/ScanMenu/ScanMenu.tsx b/app/src/components/menus/ScanMenu/ScanMenu.tsx index b4243d79..c5ef029d 100644 --- a/app/src/components/menus/ScanMenu/ScanMenu.tsx +++ b/app/src/components/menus/ScanMenu/ScanMenu.tsx @@ -14,14 +14,9 @@ import { Field } from '@/components/fields/Field/Field'; import { useMiddleEllipsis } from '@/hooks/useMiddleEllipsis'; import { useReplayAnimation } from '@/hooks/useReplayAnimation'; import { LIVE_UPDATES } from '@/state/settings/fields/updates'; -import { - CURRENT_SOURCE_IS_LOCAL, - ACTIVE_EXCLUDES, - removeExclude, - clearExcludes, -} from '@/state/stores/source'; import { EXCLUDES_DOCS_URL } from '@/constants/ui'; import { SetupGuideLink } from '@/components/app/SetupGuideLink/SetupGuideLink'; +import { useProject } from '@/state/project/context'; const PANEL_LABEL = 'Scan Settings'; @@ -54,7 +49,8 @@ function ExcludePath({ path }: { path: string }) { } function ExcludesGroup() { - const paths = ACTIVE_EXCLUDES.value; + const { source } = useProject(); + const paths = source.excludes.value; return (
@@ -68,7 +64,7 @@ function ExcludesGroup() { class="setting-row-reset" title="Restore all excluded paths" aria-label="Restore all excluded paths" - onClick={clearExcludes} + onClick={source.clearExcludes} > @@ -86,7 +82,7 @@ function ExcludesGroup() { class="setting-row-reset" title={`Restore ${p}`} aria-label={`Restore ${p}`} - onClick={() => removeExclude(p)} + onClick={() => source.removeExclude(p)} > @@ -129,10 +125,11 @@ export interface ScanMenuProps { } export function ScanMenu({ onRefresh }: ScanMenuProps) { + const { source } = useProject(); const freshness = useFreshness(); // Excluding is the one act whose result is an absence: the pane closes, the // building goes, and nothing else in the app says where it went. - const hiddenCount = ACTIVE_EXCLUDES.value.length; + const hiddenCount = source.excludes.value.length; const countRef = useReplayAnimation(hiddenCount); const status = hiddenCount ? `${freshness.titleText} · ${hiddenCount} ${hiddenCount === 1 ? 'path' : 'paths'} hidden` @@ -186,7 +183,7 @@ export function ScanMenu({ onRefresh }: ScanMenuProps) { {/* Only where it explains something: on a clone the controls above look live but nothing can change under them. */} - {!CURRENT_SOURCE_IS_LOCAL.value && ( + {!source.isLocal.value && (

Local projects only.

diff --git a/app/src/components/panes/PaneStats/statItems.ts b/app/src/components/panes/PaneStats/statItems.ts index 38a7501f..1d75eaf6 100644 --- a/app/src/components/panes/PaneStats/statItems.ts +++ b/app/src/components/panes/PaneStats/statItems.ts @@ -6,7 +6,7 @@ import type { FileNode, DirNode } from '@/types'; import { formatShortDate, formatRelativeAgeShort, parseDateMs } from '@/utils/dates'; import { formatBytes } from '@/utils/format'; import { humanLanguageFor } from '@/utils/syntaxLanguages'; -import { scrubbedStatsFor } from '@/state/stores/timeline'; +import type { TimelineStore } from '@/state/stores/timeline'; import type { PaneStatItem } from './PaneStats'; /** Direct and recursive counts as one item, collapsing to a single number when @@ -41,14 +41,17 @@ export interface FileStatOpts { dates?: boolean; /** Reference point for the relative ages. Defaults to now. */ now?: number; + /** The history this file is being read through, when it is being scrubbed: + * the replayed numbers win over the node's max-over-history ones. */ + timeline?: TimelineStore | null; } export function fileStatItems(file: FileNode, opts: FileStatOpts = {}): PaneStatItem[] { - const { dates = true, now = Date.now() } = opts; + const { dates = true, now = Date.now(), timeline = null } = opts; const items: PaneStatItem[] = []; // In Timeline the static node carries max-over-history values, so the replayed // ones win where they exist (at deletion for a file already gone). - const scrubbed = file.path != null ? scrubbedStatsFor(file.path) : null; + const scrubbed = file.path != null ? (timeline?.scrubbedStatsFor(file.path) ?? null) : null; const lines = scrubbed ? scrubbed.lines : file.lines; const size = scrubbed ? scrubbed.bytes : file.size; diff --git a/app/src/components/sources/DiscoverList/DiscoverList.tsx b/app/src/components/sources/DiscoverList/DiscoverList.tsx index feda4979..019ea29e 100644 --- a/app/src/components/sources/DiscoverList/DiscoverList.tsx +++ b/app/src/components/sources/DiscoverList/DiscoverList.tsx @@ -3,14 +3,15 @@ // stars or timings, which would rot or reopen a third-party dependency, and no // "Featured" note, since what a row can usefully say is whether you can see it. import { DISCOVER } from '@/state/stores/serverData'; -import { ACTIVE_SOURCE } from '@/state/stores/source'; +import { activeSourceOf } from '@/state/stores/source'; +import { useProject } from '@/state/project/context'; import { sameSourceIdentity } from '@/utils/sources'; import { SourceRow } from '@/components/sources/SourceRow/SourceRow'; import { cityHref } from '@/router/location'; export function DiscoverList() { const repos = DISCOVER.value; - const active = ACTIVE_SOURCE.value; + const active = activeSourceOf(useProject().source.current.value); // src alone: a Discover row names a repo, not a branch, so it is the one on // screen whichever branch is loaded. Recents compare on full identity. if (repos.length === 0) return null; diff --git a/app/src/components/sources/NewProjectForm/NewProjectForm.tsx b/app/src/components/sources/NewProjectForm/NewProjectForm.tsx index 050ced85..352b6a35 100644 --- a/app/src/components/sources/NewProjectForm/NewProjectForm.tsx +++ b/app/src/components/sources/NewProjectForm/NewProjectForm.tsx @@ -24,7 +24,7 @@ import { } from '@/components/sources/UnreachableSource/UnreachableSource'; import type { ScanErrorCode } from '@/api/manifest'; import type { SourcePayload } from '@/types/ui'; -import { SCAN_PROGRESS } from '@/state/stores/progress'; +import { useProject } from '@/state/project/context'; // Resolving a branch list means the server reaching the remote, and a typed URL // is valid for most of its last dozen characters. Wait for the typing to stop. @@ -55,6 +55,7 @@ export function NewProjectForm({ prefill, onSubmit, }: NewProjectFormProps) { + const { progress } = useProject(); // Editing the source retires the last attempt's banner. Local, not a write // back to SOURCE_ERROR: that failure is still the reason this route is open. const [retired, setRetired] = useState(false); @@ -76,7 +77,7 @@ export function NewProjectForm({ ? 'https://github.com/owner/repo or /absolute/path/to/repo' : 'https://github.com/owner/repo'; - const loading = SCAN_PROGRESS.value !== null; + const loading = progress.scan.value !== null; const activeSrc = source.trim(); // One field, classified by what's typed. Empty defaults to a URL so the // branch dropdown's absence (not a path) is the resting state. diff --git a/app/src/components/sources/RecentsList/RecentsList.tsx b/app/src/components/sources/RecentsList/RecentsList.tsx index 859c2f1c..10d94189 100644 --- a/app/src/components/sources/RecentsList/RecentsList.tsx +++ b/app/src/components/sources/RecentsList/RecentsList.tsx @@ -4,7 +4,8 @@ // entry only; the scan cache is the skip-cache control's business. import './RecentsList.css'; import { useState } from 'preact/hooks'; -import { RECENTS, removeRecent, ACTIVE_SOURCE } from '@/state/stores/source'; +import { RECENTS, removeRecent, activeSourceOf } from '@/state/stores/source'; +import { useProject } from '@/state/project/context'; import { SERVER_CONFIG } from '@/state/stores/serverData'; import { srcKind, SourceKind, sourceIdentity, sameSourceIdentity } from '@/utils/sources'; import { RecentRow } from './RecentRow'; @@ -12,7 +13,7 @@ import { cityHref } from '@/router/location'; export function RecentsList() { const recents = RECENTS.value; - const active = ACTIVE_SOURCE.value; + const active = activeSourceOf(useProject().source.current.value); const allowLocal = SERVER_CONFIG.value.allowLocalRepos; const [confirming, setConfirming] = useState(null); // key of row diff --git a/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx b/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx index bb73c47f..0dce1cde 100644 --- a/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx +++ b/app/src/components/timeline/TimelineScrubber/TimelineScrubber.tsx @@ -1,22 +1,13 @@ // components/timeline/TimelineScrubber/TimelineScrubber.tsx — the scrub track. A time axis, so busy periods -// bunch and quiet stretches spread, driving SCRUB_POS as a float commit index so +// bunch and quiet stretches spread, driving the position as a float index so // the scrub controller stays index-based. See scrubberScale for the mapping. import './TimelineScrubber.css'; import { useEffect, useMemo, useRef } from 'preact/hooks'; -import { - TIMELINE_MODE, - SCRUB_POS, - SCRUB_MAX, - SCRUB_TODAY_MS, - TIMELINE_BUNDLE, - SCRUB_DRAGGING, - setScrubPos, -} from '@/state/stores/timeline'; import { ACCENT_THEME } from '@/state/settings/fields/theme'; import { SCRUBBER } from '@/state/settings/fields/scrubber'; import { formatFullDate, formatShortDate, localDay } from '@/utils/dates'; -import { showCommit } from '@/city/sceneHandle'; +import { useProject } from '@/state/project/context'; import { buildScrubberScale, commitFraction, @@ -27,12 +18,13 @@ import { } from './scrubberScale'; export function TimelineScrubber() { - const inTimeline = TIMELINE_MODE.value; - const bundle = TIMELINE_BUNDLE.value; + const { timeline, commands } = useProject(); + const inTimeline = timeline.mode.value; + const bundle = timeline.bundle.value; const commits = bundle?.commits ?? []; const indexWeight = SCRUBBER.value.INDEX_WEIGHT; - const todayMs = SCRUB_TODAY_MS.value; + const todayMs = timeline.todayMs.value; const scale = useMemo( () => buildScrubberScale( @@ -47,8 +39,8 @@ export function TimelineScrubber() { const trackRef = useRef(null); const canvasRef = useRef(null); - const maxIndex = SCRUB_MAX.value; - const pos = SCRUB_POS.value; + const maxIndex = timeline.scrubMax.value; + const pos = timeline.scrubPos.value; // A single-commit repo has no history to scrub: the handle pins to the present // (right edge, via the scale) and the track is inert rather than grab-and-freeze. const inert = maxIndex === 0; @@ -126,21 +118,21 @@ export function TimelineScrubber() { const r = el.getBoundingClientRect(); if (r.width === 0) return; const raw = fractionToIndex(scale, (clientX - r.left) / r.width); - setScrubPos(snapToStop(scale, indexToMs(scale, raw))); + timeline.setScrubPos(snapToStop(scale, indexToMs(scale, raw))); }; const onPointerDown = (e: PointerEvent) => { if (inert) return; - SCRUB_DRAGGING.value = true; + timeline.dragging.value = true; // Optional-chained: jsdom (and old browsers) lack pointer capture. (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); setFromClientX(e.clientX); }; const onPointerMove = (e: PointerEvent) => { - if (SCRUB_DRAGGING.peek()) setFromClientX(e.clientX); + if (timeline.dragging.peek()) setFromClientX(e.clientX); }; const onPointerUp = (e: PointerEvent) => { - SCRUB_DRAGGING.value = false; + timeline.dragging.value = false; const el = e.currentTarget as HTMLElement; el.releasePointerCapture?.(e.pointerId); // Focus back to the scene, so the next R or F reaches the camera. Keyboard @@ -162,7 +154,7 @@ export function TimelineScrubber() { else return; e.preventDefault(); e.stopPropagation(); - setScrubPos(next); + timeline.setScrubPos(next); }; return ( @@ -197,7 +189,7 @@ export function TimelineScrubber() { type="button" class="timeline-scrubber-edge" title={`Jump to the first commit: ${formatFullDate(commits[0].date)}`} - onClick={() => setScrubPos(0)} + onClick={() => timeline.setScrubPos(0)} > {formatShortDate(commits[0].date)} @@ -216,7 +208,7 @@ export function TimelineScrubber() { ? `Jump to the latest commit: ${formatFullDate(commits[lastCommit].date)}` : `Jump to today: ${formatFullDate(endDay)}` } - onClick={() => setScrubPos(maxIndex)} + onClick={() => timeline.setScrubPos(maxIndex)} > {formatShortDate(endDay)} @@ -231,7 +223,7 @@ export function TimelineScrubber() { type="button" class="timeline-scrubber-commit" title="Show this commit's details" - onClick={() => showCommit(commit.sha)} + onClick={() => commands.showCommit(commit.sha)} > {commit.sha.slice(0, 7)} {commit.subject || '(no subject)'} diff --git a/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx b/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx index 9506b585..5035e600 100644 --- a/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx +++ b/app/src/components/timeline/TimelineToggle/TimelineToggle.tsx @@ -2,20 +2,19 @@ // toggle centered at the bottom of the scene (above the time-travel bar). import './TimelineToggle.css'; -import { SOURCE_INFO } from '@/state/stores/source'; -import { TIMELINE_MODE } from '@/state/stores/timeline'; -import { loadTimelineScene, exitTimelineMode } from '@/hooks/useTimelineMode'; +import { useProject } from '@/state/project/context'; export function TimelineToggle() { - if (!SOURCE_INFO.value.src) return null; - const timeline = TIMELINE_MODE.value; + const { source, timeline: history, timelineMode } = useProject(); + const timeline = history.mode.value; + if (!source.info.value.src) return null; return (
@@ -23,7 +22,7 @@ export function TimelineToggle() { type="button" class={`timeline-toggle-btn${timeline ? ' is-active' : ''}`} aria-pressed={timeline} - onClick={() => !timeline && void loadTimelineScene()} + onClick={() => !timeline && void timelineMode.loadScene()} > Timeline diff --git a/app/src/hooks/useDocumentTitle.ts b/app/src/hooks/useDocumentTitle.ts index f8ab608a..8696afa7 100644 --- a/app/src/hooks/useDocumentTitle.ts +++ b/app/src/hooks/useDocumentTitle.ts @@ -1,14 +1,15 @@ -// The single owner of document.title. Called once from . +// The single owner of document.title: it names the focused project. // // No in-progress state: PENDING_SOURCE_LABEL is set by entering Timeline too, // so a stale one stranded the tab at "(pending)". The overlay still shows it. import { useSignalEffect } from '@preact/signals'; -import { MANIFEST } from '@/state/stores/manifest'; +import { useProject } from '@/state/project/context'; export function useDocumentTitle(): void { + const { manifest } = useProject(); useSignalEffect(() => { - const m = MANIFEST.value; + const m = manifest.current.value; // tree.name is the server-normalized display name (owner/repo or basename). const label = (m as { tree?: { name?: string } } | null)?.tree?.name ?? ''; document.title = label ? `${label} — codecity` : 'codecity'; diff --git a/app/src/hooks/useHomeBackdrop.ts b/app/src/hooks/useHomeBackdrop.ts index 1f5469ca..2e5e713a 100644 --- a/app/src/hooks/useHomeBackdrop.ts +++ b/app/src/hooks/useHomeBackdrop.ts @@ -11,14 +11,9 @@ import { effect, signal, type ReadonlySignal } from '@preact/signals'; import { fetchCachedManifest, manifestUrlFor, streamManifest, ScanPhase } from '@/api/manifest'; import { SERVER_CONFIG } from '@/state/stores/serverData'; import { SILENT_BUILD_REPORTER, type BuildReporter } from '@/state/stores/progress'; -import { MANIFEST } from '@/state/stores/manifest'; -import { - RECENTS, - CURRENT_SOURCE, - BACKDROP_CITY, - BackdropKind, - type BackdropCity, -} from '@/state/stores/source'; +import { useProject } from '@/state/project/context'; +import type { ProjectSession } from '@/state/project/session'; +import { RECENTS, BACKDROP_CITY, BackdropKind, type BackdropCity } from '@/state/stores/source'; import { identityBranch, resolveBranch, sameSourceIdentity } from '@/utils/sources'; import type { Manifest } from '@/types'; @@ -55,12 +50,12 @@ function fitsBehindTheLanding(manifest: Manifest): boolean { } /** Who gets to be the backdrop, best first. */ -function candidates(featuredRepo: string | undefined): Candidate[] { +function candidates(featuredRepo: string | undefined, session: ProjectSession): Candidate[] { const out: Candidate[] = []; - // The project you just left, still in memory: no round trip, and it is the - // city you were looking at a moment ago. - const loaded = MANIFEST.peek(); - const current = CURRENT_SOURCE.peek(); + // The project you just left, still in that session: no round trip, and it is + // the city you were looking at a moment ago. + const loaded = session.manifest.current.peek(); + const current = session.source.current.peek(); if (current && loaded) { out.push({ src: current.src, @@ -97,6 +92,8 @@ export interface HomeBackdrop { } export function useHomeBackdrop(): HomeBackdrop { + // The landing's wallpaper opens on the project the focused session last had. + const session = useProject(); // Per landing visit, like the city it feeds: a second one would be a second // wallpaper, showing whatever IT picked. const backdrop = useMemo(() => { @@ -126,7 +123,7 @@ export function useHomeBackdrop(): HomeBackdrop { async function tryNext(featuredRepo?: string): Promise { const signal = controller.signal; - const next = candidates(featuredRepo).find((c) => !tried.has(`${c.kind}:${c.src}`)); + const next = candidates(featuredRepo, session).find((c) => !tried.has(`${c.kind}:${c.src}`)); if (!next) return; tried.add(`${next.kind}:${next.src}`); inFlight = true; diff --git a/app/src/hooks/useManifestSource.ts b/app/src/hooks/useManifestSource.ts index c5f3e44c..629bb154 100644 --- a/app/src/hooks/useManifestSource.ts +++ b/app/src/hooks/useManifestSource.ts @@ -1,7 +1,7 @@ -// hooks/useManifestSource.ts — The FETCH layer of the city's manifest pipeline. -// Scene-free by contract: it streams manifests and publishes canonical signals -// (MANIFEST, SCAN_PROGRESS, CURRENT_SOURCE via setCurrentSource, SOURCE_ERROR). -// The render layer consumes them and owns the apply's rebuild status. +// hooks/useManifestSource.ts — the FETCH layer for ONE project: it streams +// manifests and writes that session's stores, and nothing else. Scene-free by +// contract; the render layer consumes those signals and owns the apply's +// rebuild status. import { useEffect } from 'preact/hooks'; import { computed, effect } from '@preact/signals'; @@ -15,26 +15,11 @@ import { } from '@/api/manifest'; import { getServerConfig } from '@/api/config'; import { getDiscover } from '@/api/discover'; -import { LIVE_UPDATES, LIVE_UPDATES_ACTIVE } from '@/state/settings/fields/updates'; -import { - RECENTS, - SOURCE_ERROR, - commitSource, - CURRENT_SOURCE, - isOpenedSource, - activeExcludePathsFor, - ACTIVE_EXCLUDES, -} from '@/state/stores/source'; +import { LIVE_UPDATES, liveUpdatesActive } from '@/state/settings/fields/updates'; +import { RECENTS, activeExcludePathsFor } from '@/state/stores/source'; import { DISCOVER, SERVER_CONFIG } from '@/state/stores/serverData'; -import { MANIFEST, setManifest } from '@/state/stores/manifest'; -import { - markError, - markRebuilding, - SCAN_PROGRESS, - PENDING_SOURCE_LABEL, - CITY_ON_SCREEN, -} from '@/state/stores/progress'; -import { TIMELINE_MODE, resetTimelineMode } from '@/state/stores/timeline'; +import type { ProgressStore } from '@/state/stores/progress'; +import type { ProjectSession } from '@/state/project/session'; import { srcKind, SourceKind, identityBranch, sourceKey, sourceIdentity } from '@/utils/sources'; import { readUrlView, type UrlView } from '@/router/viewParams'; import { ROUTE_PARAMS, ROUTE_PATH } from '@/router/location'; @@ -42,13 +27,13 @@ import { ROUTES } from '@/router/paths'; import type { Manifest } from '@/types'; import type { SourcePayload } from '@/types/ui'; -// ── Shared helpers ─────────────────────────────────────────────────── - /** Consume a manifest stream, publishing SCAN_PROGRESS per event and handing * each skeleton/final to onManifest. Returns the final; throws without one. */ async function pumpManifestStream( url: string, meta: { kind: SourceKind; branch?: string }, + /** Where the stream's own progress goes: this project's readouts. */ + progress: ProgressStore, onManifest: ( manifest: Manifest, phase: ScanPhase.PartialManifest | ScanPhase.CompleteManifest @@ -66,11 +51,11 @@ async function pumpManifestStream( if ('label' in event && event.label) { // Server-side, so the document title and the overlay header name the // project the same way instead of each deriving it from the src. - PENDING_SOURCE_LABEL.value = event.label; + progress.pendingLabel.value = event.label; } if (event.phase === ScanPhase.CloneProgress || event.phase === ScanPhase.ScanProgress) { - SCAN_PROGRESS.value = { + progress.scan.value = { ...meta, phase: event.phase, percent: event.phase === ScanPhase.CloneProgress ? event.percent : undefined, @@ -86,337 +71,359 @@ async function pumpManifestStream( // tree.name beats the src basename, which for a working tree is whatever // the folder is called (e.g. a git-worktree dir). - if (event.manifest.tree?.name) PENDING_SOURCE_LABEL.value = event.manifest.tree.name; + if (event.manifest.tree?.name) progress.pendingLabel.value = event.manifest.tree.name; await onManifest(event.manifest, event.phase); lastManifest = event.manifest; if (event.phase === ScanPhase.PartialManifest) appliedPending = event.manifest.pending; // After onManifest, so the overlay reaction can never see "heights final" // ahead of the paint that shows them. - SCAN_PROGRESS.value = { ...meta, phase: event.phase, appliedPending }; + progress.scan.value = { ...meta, phase: event.phase, appliedPending }; } if (!lastManifest) throw new Error('No manifest received'); return lastManifest; } -// Injected, not imported (importing useTimelineMode back was a cycle); it -// registers before TIMELINE_MODE can turn on. -type TimelineRefresh = (opts?: { noCache?: boolean; overlay?: boolean }) => Promise; - -let timelineRefresh: TimelineRefresh | null = null; - -export function setTimelineRefreshHandler(fn: TimelineRefresh | null): void { - timelineRefresh = fn; -} - -/** The Timeline boot: `?mode=timeline` loads the history bundle instead of a - * HEAD scan, and commits its own manifest. Injected for the same cycle reason. */ -type TimelineBoot = (payload: { src: string; branch?: string; commit?: string }) => Promise; - -let timelineBoot: TimelineBoot | null = null; - -export function setTimelineBootHandler(fn: TimelineBoot | null): void { - timelineBoot = fn; +/** Fetching one project. Every write lands in that session's stores, so two of + * these can stream different repos at once without a shared generation. */ +export interface ProjectLoader { + /** The one way to load a source into this session. */ + loadSource(payload: SourcePayload): Promise; + /** Abort the in-flight foreground load. A clean cancel, not a failure. */ + cancel(): void; + /** Re-read the source already open, in whichever mode it is being viewed. */ + refresh(skipCache?: boolean): void; + /** Load whatever the URL names, in the mode it asks for. */ + boot(view: UrlView): Promise; + /** Follow the URL into this session: attach for the focused one only. */ + attachRouteLoad(): () => void; + /** Start the live-update poll + exclude-refresh reaction. */ + setupLiveUpdates(): () => void; + dispose(): void; } -// ── Single-writer generation guard ─────────────────────────────────── - -// Every write is gated on "am I still the current generation?", so a newer load -// silently drops an older one and any in-flight poll write with it. -let loadGeneration = 0; - -// The AbortController for the current foreground load, so the UI can cancel a -// slow clone/scan. A new load or a cancel aborts the previous one. -let loadController: AbortController | null = null; - -// ── Canonical source load (cold-boot + user switch) ────────────────── - -// The one way to load a source. The poll below is a separate op that shares -// only the MANIFEST sink and yields to this via the generation. -export async function loadSource(payload: SourcePayload): Promise { - // This attempt supersedes the last failure, so nothing outlives it to explain - // a load that is no longer the current one. - SOURCE_ERROR.value = null; - // A source switch always exits Timeline; the city layer reacts to the flip. - if (TIMELINE_MODE.peek()) resetTimelineMode(); - const myGen = ++loadGeneration; // claim authority; supersedes any in-flight load/poll - loadController?.abort(); // supersede any in-flight load - const controller = new AbortController(); - loadController = controller; - // A local source has no branch axis, so a stale deep-link's branch is dropped - // rather than carried into the URL, the overlay and the committed source. - const branch = identityBranch(payload.src, payload.branch); - // The server's label arrives with the first stream event, by which time the - // overlay is already up; recents cover the gap. - PENDING_SOURCE_LABEL.value = RECENTS.peek().find((r) => r.src === payload.src)?.label ?? null; - const meta = { - kind: srcKind(payload.src), - branch, - }; - SCAN_PROGRESS.value = { ...meta, phase: null }; // show overlay immediately - // What a cancel rolls back to, captured before the clear below: otherwise the - // canceled repo's geometry lingers under the unchanged header. - const prevManifest = MANIFEST.peek(); - // What is on screen stays only while re-scanning the very project it shows: - // otherwise the manifest in hand builds a city this load is about to replace. - const keepCityUp = isOpenedSource(payload.src, branch) && CITY_ON_SCREEN.peek(); - if (!keepCityUp) setManifest(null); - - try { - const url = manifestUrlFor({ - src: payload.src, +export function createProjectLoader(session: ProjectSession): ProjectLoader { + const { source, manifest, progress, timeline } = session; + + // ── Single-writer generation guard ─────────────────────────────────── + + // Every write is gated on "am I still the current generation?", so a newer load + // silently drops an older one and any in-flight poll write with it. + let loadGeneration = 0; + + // The AbortController for the current foreground load, so the UI can cancel a + // slow clone/scan. A new load or a cancel aborts the previous one. + let loadController: AbortController | null = null; + + // ── Canonical source load (cold-boot + user switch) ────────────────── + + // The one way to load a source. The poll below is a separate op that shares + // only the MANIFEST sink and yields to this via the generation. + async function loadSource(payload: SourcePayload): Promise { + // This attempt supersedes the last failure, so nothing outlives it to explain + // a load that is no longer the current one. + source.error.value = null; + // A source switch always exits Timeline; the city layer reacts to the flip. + if (timeline.mode.peek()) timeline.reset(); + const myGen = ++loadGeneration; // claim authority; supersedes any in-flight load/poll + loadController?.abort(); // supersede any in-flight load + const controller = new AbortController(); + loadController = controller; + // A local source has no branch axis, so a stale deep-link's branch is dropped + // rather than carried into the URL, the overlay and the committed source. + const branch = identityBranch(payload.src, payload.branch); + // The server's label arrives with the first stream event, by which time the + // overlay is already up; recents cover the gap. + progress.pendingLabel.value = RECENTS.peek().find((r) => r.src === payload.src)?.label ?? null; + const meta = { + kind: srcKind(payload.src), branch, - noCache: !!payload.skipCache, - exclude: activeExcludePathsFor(payload.src), - }); - // Skeleton streams out here; the final is published below, after the - // source is committed. - const manifest = await pumpManifestStream( - url, - meta, - (m, phase) => { - if (phase === ScanPhase.PartialManifest && myGen === loadGeneration) setManifest(m); - }, - controller.signal - ); - // A newer load superseded this one: it owns MANIFEST now, don't touch. - if (myGen !== loadGeneration) return; - // An aborted stream ends as done, not a throw, so a cancel arrives here - // holding the partial. Roll back rather than commit it. - if (controller.signal.aborted) { - setManifest(prevManifest); - return; - } - // One commit point, whichever view loaded it: source, recents, manifest. - commitSource(payload.src, branch, manifest); - } catch (err) { - if (myGen !== loadGeneration) return; // superseded — its error isn't current - if (controller.signal.aborted) { - setManifest(prevManifest); // user canceled: not an error; roll back any skeleton - return; - } - SOURCE_ERROR.value = { - error: err instanceof Error ? err.message : String(err), - code: err instanceof ScanError ? err.code : undefined, - prefill: { src: payload.src, branch }, }; - } finally { - // Only the authoritative load tears the overlay down, or a superseded one - // clears it out from under the load still streaming. - if (myGen === loadGeneration) { - SCAN_PROGRESS.value = null; - if (loadController === controller) loadController = null; + progress.scan.value = { ...meta, phase: null }; // show overlay immediately + // What a cancel rolls back to, captured before the clear below: otherwise the + // canceled repo's geometry lingers under the unchanged header. + const prevManifest = manifest.current.peek(); + // What is on screen stays only while re-scanning the very project it shows: + // otherwise the manifest in hand builds a city this load is about to replace. + const keepCityUp = source.isOpen(payload.src, branch) && progress.cityOnScreen.peek(); + if (!keepCityUp) manifest.set(null); + + try { + const url = manifestUrlFor({ + src: payload.src, + branch, + noCache: !!payload.skipCache, + exclude: activeExcludePathsFor(payload.src), + }); + // Skeleton streams out here; the final is published below, after the + // source is committed. + const loaded = await pumpManifestStream( + url, + meta, + progress, + (m, phase) => { + if (phase === ScanPhase.PartialManifest && myGen === loadGeneration) manifest.set(m); + }, + controller.signal + ); + // A newer load superseded this one: it owns MANIFEST now, don't touch. + if (myGen !== loadGeneration) return; + // An aborted stream ends as done, not a throw, so a cancel arrives here + // holding the partial. Roll back rather than commit it. + if (controller.signal.aborted) { + manifest.set(prevManifest); + return; + } + // One commit point, whichever view loaded it: source, recents, manifest. + source.commit(payload.src, branch, loaded); + } catch (err) { + if (myGen !== loadGeneration) return; // superseded — its error isn't current + if (controller.signal.aborted) { + manifest.set(prevManifest); // user canceled: not an error; roll back any skeleton + return; + } + source.error.value = { + error: err instanceof Error ? err.message : String(err), + code: err instanceof ScanError ? err.code : undefined, + prefill: { src: payload.src, branch }, + }; + } finally { + // Only the authoritative load tears the overlay down, or a superseded one + // clears it out from under the load still streaming. + if (myGen === loadGeneration) { + progress.scan.value = null; + if (loadController === controller) loadController = null; + } } } -} -/** Abort the in-flight foreground load. Treated as a clean user cancel, not a - * failure: see the `catch` branch above. */ -export function cancelLoad(): void { - loadController?.abort(); -} + /** Abort the in-flight foreground load. Treated as a clean user cancel, not a + * failure: see the `catch` branch above. */ + function cancelLoad(): void { + loadController?.abort(); + } -/** Re-read the source already open, in whichever mode it is being viewed: - * Timeline refetches its bundle in place rather than dropping to live HEAD. */ -export function refreshCurrentSource(skipCache = false): void { - const cur = CURRENT_SOURCE.peek(); - if (!cur) return; - if (TIMELINE_MODE.peek() && timelineRefresh) { - // Asked for by hand, so it gets the same stepped overlay a Live refresh - // does: the history walk behind it runs for minutes on a big repo. - void timelineRefresh({ noCache: skipCache, overlay: true }); - return; + /** Re-read the source already open, in whichever mode it is being viewed: + * Timeline refetches its bundle in place rather than dropping to live HEAD. */ + function refreshCurrentSource(skipCache = false): void { + const cur = source.current.peek(); + if (!cur) return; + if (timeline.mode.peek()) { + // Asked for by hand, so it gets the same stepped overlay a Live refresh + // does: the history walk behind it runs for minutes on a big repo. + void session.timelineMode.loadScene({ inPlace: true, noCache: skipCache, overlay: true }); + return; + } + void loadSource({ src: cur.src, branch: cur.branch, skipCache: skipCache || undefined }); } - void loadSource({ src: cur.src, branch: cur.branch, skipCache: skipCache || undefined }); -} -// ── Live-update poll loop ──────────────────────────────────────────── + // ── Live-update poll loop ──────────────────────────────────────────── -// Floor: the server walks the filesystem per poll, so tighter burns CPU. -// Ceiling: past a minute "live" stops feeling live. -const POLL_SECONDS_MIN = 1; -const POLL_SECONDS_MAX = 60; + // Floor: the server walks the filesystem per poll, so tighter burns CPU. + // Ceiling: past a minute "live" stops feeling live. + const POLL_SECONDS_MIN = 1; + const POLL_SECONDS_MAX = 60; -function _clampPollSeconds(s: number | unknown): number { - if (typeof s !== 'number' || !isFinite(s)) return POLL_SECONDS_MIN; - return Math.min(POLL_SECONDS_MAX, Math.max(POLL_SECONDS_MIN, s)); -} + function _clampPollSeconds(s: number | unknown): number { + if (typeof s !== 'number' || !isFinite(s)) return POLL_SECONDS_MIN; + return Math.min(POLL_SECONDS_MAX, Math.max(POLL_SECONDS_MIN, s)); + } -interface SignatureResponse { - root: string; - scanned_at: string; - content_signature: string; -} + interface SignatureResponse { + root: string; + scanned_at: string; + content_signature: string; + } -/** Start the live-update poll loop and the exclude-refresh reaction, returning - * a dispose for both. Exported so the reaction is directly testable. */ -export function setupLiveUpdates(): () => void { - let timer: number | null = null; - let inFlight = false; + /** Start the live-update poll loop and the exclude-refresh reaction, returning + * a dispose for both. Exported so the reaction is directly testable. */ + function setupLiveUpdates(): () => void { + let timer: number | null = null; + let inFlight = false; + + async function fetchAndApply(src: string, branch: string | undefined): Promise { + const myGen = loadGeneration; // capture; a foreground load bumping this drops our write + try { + for await (const event of streamManifest( + manifestUrlFor({ src, branch, exclude: activeExcludePathsFor(src) }) + )) { + if (event.phase === ScanPhase.Error) throw new ScanError(event.error, event.code); + // Skip the skeleton: the city is already drawn, and applying one would + // animate every building to placeholder heights and back on each save. + if (event.phase !== ScanPhase.CompleteManifest) continue; + if (myGen !== loadGeneration) return; // a foreground load started — this refresh is stale + const m = event.manifest; + if (m?.content_signature) manifest.set(m); + } + } catch (err) { + if (myGen !== loadGeneration) return; // superseded by a load — not our error to surface + progress.reporter.markError(err); + } + } - async function fetchAndApply(src: string, branch: string | undefined): Promise { - const myGen = loadGeneration; // capture; a foreground load bumping this drops our write - try { - for await (const event of streamManifest( - manifestUrlFor({ src, branch, exclude: activeExcludePathsFor(src) }) - )) { - if (event.phase === ScanPhase.Error) throw new ScanError(event.error, event.code); - // Skip the skeleton: the city is already drawn, and applying one would - // animate every building to placeholder heights and back on each save. - if (event.phase !== ScanPhase.CompleteManifest) continue; - if (myGen !== loadGeneration) return; // a foreground load started — this refresh is stale - const m = event.manifest; - if (m?.content_signature) setManifest(m); + // Cheap signature first, full manifest only when it differs. Targets the + // committed CURRENT_SOURCE, not the page URL, which lags a switch. + async function tick(): Promise { + if (inFlight) return; + if (timeline.mode.peek()) return; // Timeline mode owns the scene (union city + scrub) — no live poll + if (progress.scan.peek() !== null) return; // a foreground load is in flight — yield + const cur = source.current.peek(); + if (!cur) return; // nothing loaded yet + const current = manifest.current.peek(); + if (!current) return; + const applied = (current as Manifest).content_signature; + inFlight = true; + try { + const sigResp = await fetch( + signatureUrlFor(cur.src, cur.branch, activeExcludePathsFor(cur.src)) + ); + if (!sigResp.ok) return; + const sig: SignatureResponse | null = await sigResp.json(); + if (!sig?.content_signature || sig.content_signature === applied) return; + await fetchAndApply(cur.src, cur.branch); + } catch (_) { + // Cheap-probe network blip: no rebuild attempted, so not surfaced. Next + // tick retries. + } finally { + inFlight = false; } - } catch (err) { - if (myGen !== loadGeneration) return; // superseded by a load — not our error to surface - markError(err); } - } - // Cheap signature first, full manifest only when it differs. Targets the - // committed CURRENT_SOURCE, not the page URL, which lags a switch. - async function tick(): Promise { - if (inFlight) return; - if (TIMELINE_MODE.peek()) return; // Timeline mode owns the scene (union city + scrub) — no live poll - if (SCAN_PROGRESS.peek() !== null) return; // a foreground load is in flight — yield - const cur = CURRENT_SOURCE.peek(); - if (!cur) return; // nothing loaded yet - const current = MANIFEST.peek(); - if (!current) return; - const applied = (current as Manifest).content_signature; - inFlight = true; - try { - const sigResp = await fetch( - signatureUrlFor(cur.src, cur.branch, activeExcludePathsFor(cur.src)) - ); - if (!sigResp.ok) return; - const sig: SignatureResponse | null = await sigResp.json(); - if (!sig?.content_signature || sig.content_signature === applied) return; - await fetchAndApply(cur.src, cur.branch); - } catch (_) { - // Cheap-probe network blip: no rebuild attempted, so not surfaced. Next - // tick retries. - } finally { - inFlight = false; + function start(): void { + stop(); + const seconds = _clampPollSeconds(LIVE_UPDATES.value.POLL_SECONDS); + timer = window.setInterval(tick, seconds * 1000); } - } + function stop(): void { + if (timer != null) { + window.clearInterval(timer); + timer = null; + } + } + + const disposeEnabledEffect = effect(() => { + // Tracks the source too, so switching between a local tree and a clone + // starts or stops the timer without a reload. + if (liveUpdatesActive(source)) start(); + else stop(); + }); + + // Excludes need their own trigger, since the poll is gated on + // the poll being active. Key-guarded so a source switch isn't read as an edit. + let lastExcludeKey: string | null = null; + const disposeExcludeRefresh = effect(() => { + const serialized = source.excludes.value.join('\n'); + const cur = source.current.peek(); + const repoKey = cur ? sourceKey(cur.src) : null; + const nextKey = repoKey === null ? null : `${repoKey}|${serialized}`; + const prev = lastExcludeKey; + lastExcludeKey = nextKey; + if (prev === null || nextKey === null) return; // first run / no source + const [prevRepo] = prev.split('|', 1); + if (prevRepo !== repoKey) return; // source switched — the load owns it + if (prev === nextKey) return; // no actual change + if (progress.scan.peek() !== null) return; // yield to a foreground load + if (!cur) return; + if (inFlight) return; // the poll's tick is already covering this refresh + inFlight = true; + // Timeline owns the scene: excludes change the union data, so refetch its + // bundle + re-pack (it reports itself through the readout). Live: re-scan. + let refresh: Promise; + if (timeline.mode.peek()) { + refresh = session.timelineMode.loadScene({ inPlace: true }); + } else { + progress.reporter.markRebuilding(); // say so now, not after the re-scan streams back + refresh = fetchAndApply(cur.src, cur.branch); + } + void refresh.finally(() => { + inFlight = false; + }); + }); - function start(): void { - stop(); - const seconds = _clampPollSeconds(LIVE_UPDATES.value.POLL_SECONDS); - timer = window.setInterval(tick, seconds * 1000); + return () => { + stop(); + disposeEnabledEffect(); + disposeExcludeRefresh(); + }; } - function stop(): void { - if (timer != null) { - window.clearInterval(timer); - timer = null; + + /** The load the URL asks for, in the mode it asks for. A Timeline boot that + * fails to engage falls through to Live, so the page lands on a working city. */ + async function bootLoad(boot: UrlView): Promise { + const src = boot.src; + if (!src) return; + if (boot.timeline) { + await session.timelineMode.loadSource({ + src, + branch: boot.branch, + commit: boot.commit ?? undefined, + }); + if (timeline.mode.peek()) return; } + await loadSource({ src, branch: boot.branch }); } - const disposeEnabledEffect = effect(() => { - // Tracks the source too, so switching between a local tree and a clone - // starts or stops the timer without a reload. - if (LIVE_UPDATES_ACTIVE.value) start(); - else stop(); + /** The project the URL asks for, as one comparable string: a computed notifies + * only when THAT changes, so writing ?sel= or ?mode= cannot re-ask it. */ + const URL_SOURCE = computed(() => { + if (ROUTE_PATH.value !== ROUTES.CITY) return ''; + const view = readUrlView(ROUTE_PARAMS.value); + return view.src ? sourceIdentity(view.src, view.branch) : ''; }); - // Excludes need their own trigger, since the poll is gated on - // LIVE_UPDATES_ACTIVE. Key-guarded so a source switch isn't read as an edit. - let lastExcludeKey: string | null = null; - const disposeExcludeRefresh = effect(() => { - const serialized = ACTIVE_EXCLUDES.value.join('\n'); - const cur = CURRENT_SOURCE.peek(); - const repoKey = cur ? sourceKey(cur.src) : null; - const nextKey = repoKey === null ? null : `${repoKey}|${serialized}`; - const prev = lastExcludeKey; - lastExcludeKey = nextKey; - if (prev === null || nextKey === null) return; // first run / no source - const [prevRepo] = prev.split('|', 1); - if (prevRepo !== repoKey) return; // source switched — the load owns it - if (prev === nextKey) return; // no actual change - if (SCAN_PROGRESS.peek() !== null) return; // yield to a foreground load - if (!cur) return; - if (inFlight) return; // the poll's tick is already covering this refresh - inFlight = true; - // Timeline owns the scene: excludes change the union data, so refetch its - // bundle + re-pack (it reports itself through the readout). Live: re-scan. - let refresh: Promise; - if (TIMELINE_MODE.peek() && timelineRefresh) { - refresh = timelineRefresh(); - } else { - markRebuilding(); // say so now, not after the re-scan streams back - refresh = fetchAndApply(cur.src, cur.branch); - } - void refresh.finally(() => { - inFlight = false; + /** Load whatever project the URL names, whenever that changes: the boot read + * and every Back/Forward between cities are the same event. Returns a dispose. */ + function attachRouteLoad(): () => void { + // Whether the URL named a city on the last run, so this one can tell arriving + // at one from moving around inside it. + let onCityRoute = false; + return effect(() => { + if (!URL_SOURCE.value) { + onCityRoute = false; + return; + } + const arriving = !onCityRoute; + onCityRoute = true; + // Peeked: the identity above is the trigger, and re-reading the params here + // must not subscribe this to the view ones alongside it. + const boot = readUrlView(ROUTE_PARAMS.peek()); + // Out of the tracking scope: the load writes signals this effect reads. + queueMicrotask(() => { + // Arriving always asks: only the server knows if its scan still holds. + // Once here, that project again is the branch a commit put in the URL. + if (!arriving && source.isOpen(boot.src, boot.branch)) return; + void bootLoad(boot); + }); }); - }); - - return () => { - stop(); - disposeEnabledEffect(); - disposeExcludeRefresh(); - }; -} - -/** The load the URL asks for, in the mode it asks for. A Timeline boot that - * fails to engage falls through to Live, so the page lands on a working city. */ -export async function bootLoad(boot: UrlView): Promise { - const src = boot.src; - if (!src) return; - if (boot.timeline && timelineBoot) { - await timelineBoot({ src, branch: boot.branch, commit: boot.commit ?? undefined }); - if (TIMELINE_MODE.peek()) return; } - await loadSource({ src, branch: boot.branch }); -} -/** The project the URL asks for, as one comparable string: a computed notifies - * only when THAT changes, so writing ?sel= or ?mode= cannot re-ask it. */ -const URL_SOURCE = computed(() => { - if (ROUTE_PATH.value !== ROUTES.CITY) return ''; - const view = readUrlView(ROUTE_PARAMS.value); - return view.src ? sourceIdentity(view.src, view.branch) : ''; -}); - -/** Load whatever project the URL names, whenever that changes: the boot read - * and every Back/Forward between cities are the same event. Returns a dispose. */ -export function attachRouteLoad(): () => void { - // Whether the URL named a city on the last run, so this one can tell arriving - // at one from moving around inside it. - let onCityRoute = false; - return effect(() => { - if (!URL_SOURCE.value) { - onCityRoute = false; - return; - } - const arriving = !onCityRoute; - onCityRoute = true; - // Peeked: the identity above is the trigger, and re-reading the params here - // must not subscribe this to the view ones alongside it. - const boot = readUrlView(ROUTE_PARAMS.peek()); - // Out of the tracking scope: the load writes signals this effect reads. - queueMicrotask(() => { - // Arriving always asks: only the server knows if its scan still holds. - // Once here, that project again is the branch a commit put in the URL. - if (!arriving && isOpenedSource(boot.src, boot.branch)) return; - void bootLoad(boot); - }); - }); + const disposers: (() => void)[] = []; + return { + loadSource, + cancel: cancelLoad, + refresh: refreshCurrentSource, + boot: bootLoad, + attachRouteLoad, + setupLiveUpdates() { + const stop = setupLiveUpdates(); + disposers.push(stop); + return stop; + }, + dispose: () => disposers.splice(0).forEach((stop) => stop()), + }; } // ── Hook ───────────────────────────────────────────────────────────── /** Boot the fetch pipeline on mount. Nothing to hand back: loadSource and * friends are module functions, so each view calls the one it needs. */ -export function useManifestSource(): void { +export function useManifestSource(session: ProjectSession): void { useEffect(() => { let cancelled = false; let disposeLiveUpdates: (() => void) | null = null; // The URL drives what is rendered: boot read and every later Back/Forward. // A bare ?src is complete; the server resolves the default branch. - const disposeRouteLoad = attachRouteLoad(); + const disposeRouteLoad = session.load.attachRouteLoad(); (async () => { // Independent boot reads, so they go out together rather than making the // landing wait for two round trips in series. @@ -427,7 +434,7 @@ export function useManifestSource(): void { // One loop for the app's lifetime: it re-reads the canonical signals per // tick, so boot and every switch are covered without restarting it. - disposeLiveUpdates = setupLiveUpdates(); + disposeLiveUpdates = session.load.setupLiveUpdates(); })(); return () => { cancelled = true; diff --git a/app/src/hooks/useTimelineMode.ts b/app/src/hooks/useTimelineMode.ts index afc226bb..8ea5fbbd 100644 --- a/app/src/hooks/useTimelineMode.ts +++ b/app/src/hooks/useTimelineMode.ts @@ -1,32 +1,12 @@ -// hooks/useTimelineMode.ts — enter/exit the explicit Timeline mode. Enter packs -// the union city once and installs the scrub controller; exit reloads live HEAD. -// Every exit path only flips TIMELINE_MODE: the city layer (city/index.ts) -// reacts to that and does the scene teardown itself. +// hooks/useTimelineMode.ts — enter/exit the explicit Timeline mode for ONE +// project. Enter packs the union city once and installs the scrub controller; +// exit reloads live HEAD. Every exit path only flips the mode: the city layer +// (city/index.ts) reacts to that and does the scene teardown itself. import { fetchTimelineBundle } from '@/api/timeline'; import { buildPathTimelines } from '@/city/timeline/replay'; -import { - CURRENT_SOURCE, - SOURCE_INFO, - RECENTS, - commitSource, - activeExcludePathsFor, -} from '@/state/stores/source'; -import { SCENE_HANDLE, whenSceneHandle } from '@/city/sceneHandle'; -import { - beginBuild, - enterBuildStage, - setBuildStagePercent, - markError, - markRebuilding, - setRebuildDetail, - showLoadingOverlay, - setLoadingStep, - setLoadingStepTail, - hideLoadingOverlay, - setLoadingCancel, - PENDING_SOURCE_LABEL, -} from '@/state/stores/progress'; +import { RECENTS, activeExcludePathsFor } from '@/state/stores/source'; +import { whenCity } from '@/city/sceneHandle'; import { BuildStage, PACK_STAGES, @@ -37,22 +17,8 @@ import { } from '@/constants/progress'; import { nextPaint } from '@/city/utils/nextPaint'; import { srcKind } from '@/utils/sources'; -import { - TIMELINE_MODE, - TIMELINE_BUNDLE, - SCRUB_POS, - SCRUB_MAX, - resetTimelineMode, - beginTimelineMode, - setScrubPos, -} from '@/state/stores/timeline'; -import { - loadSource, - cancelLoad, - setTimelineRefreshHandler, - setTimelineBootHandler, -} from '@/hooks/useManifestSource'; import { TimelineStage } from '@/types'; +import type { ProjectSession } from '@/state/project/session'; import type { Manifest, TimelineBundle, TimelineProgress } from '@/types'; /** How far the current stage has got. Written beside its own step row, and @@ -70,212 +36,252 @@ function timelineStageTail(p: TimelineProgress): string | null { /** Load a source in Timeline: its own call and manifest, committed the way Live * commits its scan. `inPlace` is the refetch that holds the scrub. */ -export async function loadTimelineSource({ - src, - branch, - commit, - inPlace = false, - noCache = false, - overlay = !inPlace, -}: { - src: string; - branch?: string; - commit?: string; - inPlace?: boolean; - noCache?: boolean; - overlay?: boolean; -}): Promise { - const abort = new AbortController(); - let cancelled = false; - let committed = false; +/** Everything Timeline does to one project. Held by its session, so a second + * project scrubs its own history with its own overlay and its own city. */ +export interface TimelineController { + /** Enter Timeline for an explicit source (the `?mode=timeline` boot). */ + loadSource(payload: { + src: string; + branch?: string; + commit?: string; + inPlace?: boolean; + noCache?: boolean; + overlay?: boolean; + }): Promise; + /** Enter Timeline for the source already open. */ + loadScene(opts?: { + inPlace?: boolean; + noCache?: boolean; + overlay?: boolean; + commit?: string; + }): Promise; + /** Enter if it isn't on, then scrub to that commit. */ + viewCommit(sha: string): Promise; + /** Re-pack from the warm bundle, holding the position: a settings Save in + * Timeline stays in Timeline rather than dropping to live HEAD. */ + reapply(): Promise; + /** Scene-free: the city layer reacts to the mode and tears its scene down. */ + teardown(): void; + /** Leave, and reload live HEAD where the scrubber was. */ + exit(): void; +} - // Unoverlaid, the readout is the only progress surface: say so now, and the - // stage tails land beside it. Overlaid, a cancel has nothing to unwind. - if (inPlace && !overlay) markRebuilding(); - if (overlay) { - // Cancelling keeps whatever is on screen: nothing is touched until the pack - // below sets `committed`. - PENDING_SOURCE_LABEL.value = - SOURCE_INFO.peek().label || RECENTS.peek().find((r) => r.src === src)?.label || null; - showLoadingOverlay({ kind: srcKind(src), branch, steps: TIMELINE_LOADING_STEPS }, () => { - if (committed) return; - cancelled = true; - abort.abort(); - hideLoadingOverlay(); - }); - } +export function createTimelineController(session: ProjectSession): TimelineController { + const { source, progress, timeline, city } = session; + const report = progress.reporter; - // A cold boot can outrun the city it packs into; a refetch can't, so no handle - // there means nothing to refetch and waiting would hang the overlay. - if (inPlace && !SCENE_HANDLE.peek()) return; - const handle = await whenSceneHandle(); - if (cancelled) return; + async function loadTimelineSource({ + src, + branch, + commit, + inPlace = false, + noCache = false, + overlay = !inPlace, + }: { + src: string; + branch?: string; + commit?: string; + inPlace?: boolean; + noCache?: boolean; + overlay?: boolean; + }): Promise { + const abort = new AbortController(); + let cancelled = false; + let committed = false; + + // Unoverlaid, the readout is the only progress surface: say so now, and the + // stage tails land beside it. Overlaid, a cancel has nothing to unwind. + if (inPlace && !overlay) report.markRebuilding(); + if (overlay) { + // Cancelling keeps whatever is on screen: nothing is touched until the pack + // below sets `committed`. + progress.pendingLabel.value = + source.info.peek().label || RECENTS.peek().find((r) => r.src === src)?.label || null; + progress.showOverlay({ kind: srcKind(src), branch, steps: TIMELINE_LOADING_STEPS }, () => { + if (committed) return; + cancelled = true; + abort.abort(); + progress.hideOverlay(); + }); + } + + // A cold boot can outrun the city it packs into; a refetch can't, so no handle + // there means nothing to refetch and waiting would hang the overlay. + if (inPlace && !city.peek()) return; + const handle = await whenCity(city); + if (cancelled) return; - // The plan the whole Building row counts over, opened when the server starts - // assembling and reopened identically below so the bundle can't reset it. - const buildPlan = (clientStages: readonly BuildStage[]): BuildStage[] => [ - BuildStage.Assembling, - BuildStage.Replay, - ...clientStages, - ]; + // The plan the whole Building row counts over, opened when the server starts + // assembling and reopened identically below so the bundle can't reset it. + const buildPlan = (clientStages: readonly BuildStage[]): BuildStage[] => [ + BuildStage.Assembling, + BuildStage.Replay, + ...clientStages, + ]; - // One row per server stage, so a stall is attributable to the stage it is in - // and the rows below say what is still to come. - let assemblyOpen = false; - const onProgress = (p: TimelineProgress): void => { - if (p.stage === TimelineStage.Assemble) { - // The server's wait, but the same wait as the build after it: one readout. + // One row per server stage, so a stall is attributable to the stage it is in + // and the rows below say what is still to come. + let assemblyOpen = false; + const onProgress = (p: TimelineProgress): void => { + if (p.stage === TimelineStage.Assemble) { + // The server's wait, but the same wait as the build after it: one readout. - if (!assemblyOpen) { - assemblyOpen = true; - beginBuild(buildPlan(PACK_STAGES)); + if (!assemblyOpen) { + assemblyOpen = true; + report.beginBuild(buildPlan(PACK_STAGES)); + } + if (p.percent != null) report.setBuildStagePercent(p.percent); + if (overlay) progress.setStep(LoadingStep.Building); + return; } - if (p.percent != null) setBuildStagePercent(p.percent); - if (overlay) setLoadingStep(LoadingStep.Building); - return; - } - const tail = timelineStageTail(p); - if (!overlay) { - setRebuildDetail(tail); - return; - } - const step = stepForTimelineStage(p.stage); - setLoadingStep(step); - setLoadingStepTail(step, tail); - }; + const tail = timelineStageTail(p); + if (!overlay) { + progress.setDetail(tail); + return; + } + const step = stepForTimelineStage(p.stage); + progress.setStep(step); + progress.setStepTail(step, tail); + }; - try { - const bundle = await fetchTimelineBundle(src, branch, onProgress, { - signal: abort.signal, - exclude: activeExcludePathsFor(src), - noCache, - }); - if (cancelled) return; // user backed out during the fetch — live view stands - committed = true; // past here the scene is repacked; no longer cancellable - TIMELINE_BUNDLE.value = bundle; - if (overlay) setLoadingStep(LoadingStep.Building); - markRebuilding(); - // The bundle's union manifest is a Manifest like any other — repo info, - // commits, signals — so the panes, header and tree read Timeline's own. - const manifest = bundle.unionManifest as unknown as Manifest; - // The replay and the fan-out below run before the apply that would name - // them: open the same plan the assembly did, and paint before the freeze. - beginBuild(buildPlan(handle.buildStagesFor(manifest))); - enterBuildStage(BuildStage.Replay); - await nextPaint(); - const timelines = buildPathTimelines(bundle); - // Before the manifest: the mode is what tells the scene layer whose city to - // pack, and the commit below would otherwise land as a live one. - beginTimelineMode(); - commitSource(src, branch, manifest); - await handle.applyManifest(manifest, [BuildStage.Assembling, BuildStage.Replay]); - // Flip after the pack: applyManifest rebuilds the street + footprint meshes opaque. - handle.timeline.setStreetsTransparent(true); - handle.timeline.setFootprintsTransparent(true); - handle.timeline.installScrubController(timelines, bundle.commitLineRanges); - // A refetch holds position (self-clamping if the bundle shrank), a named - // commit rests on it, and anything else opens at the present. - setScrubPos(scrubTarget(bundle, commit, inPlace)); - if (overlay) { - // The pack returns before its meshes are drawn: wait for the frame that - // carries them, or the reveal lands on a half-built city. - await handle.whenOnScreen(); - hideLoadingOverlay(); - } - } catch (err) { - if (cancelled) return; // user cancel already aborted the fetch + restored live - // A failure can predate the controller install, so the effect wouldn't - // fire: tear down here, best-effort so its own throw can't bury `err`. - if (!inPlace) { - try { - resetTimelineMode(); - handle.timeline.uninstallScrubController(); - handle.timeline.setStreetsTransparent(false); - handle.timeline.setFootprintsTransparent(false); - } catch { - /* teardown failed; surfacing err + hiding the overlay below is what matters */ + try { + const bundle = await fetchTimelineBundle(src, branch, onProgress, { + signal: abort.signal, + exclude: activeExcludePathsFor(src), + noCache, + }); + if (cancelled) return; // user backed out during the fetch — live view stands + committed = true; // past here the scene is repacked; no longer cancellable + timeline.bundle.value = bundle; + if (overlay) progress.setStep(LoadingStep.Building); + report.markRebuilding(); + // The bundle's union manifest is a Manifest like any other — repo info, + // commits, signals — so the panes, header and tree read Timeline's own. + const manifest = bundle.unionManifest as unknown as Manifest; + // The replay and the fan-out below run before the apply that would name + // them: open the same plan the assembly did, and paint before the freeze. + report.beginBuild(buildPlan(handle.buildStagesFor(manifest))); + report.enterBuildStage(BuildStage.Replay); + await nextPaint(); + const timelines = buildPathTimelines(bundle); + // Before the manifest: the mode is what tells the scene layer whose city to + // pack, and the commit below would otherwise land as a live one. + timeline.begin(); + source.commit(src, branch, manifest); + await handle.applyManifest(manifest, [BuildStage.Assembling, BuildStage.Replay]); + // Flip after the pack: applyManifest rebuilds the street + footprint meshes opaque. + handle.timeline.setStreetsTransparent(true); + handle.timeline.setFootprintsTransparent(true); + handle.timeline.installScrubController(timelines, bundle.commitLineRanges); + // A refetch holds position (self-clamping if the bundle shrank), a named + // commit rests on it, and anything else opens at the present. + timeline.setScrubPos(scrubTarget(bundle, commit, inPlace)); + if (overlay) { + // The pack returns before its meshes are drawn: wait for the frame that + // carries them, or the reveal lands on a half-built city. + await handle.whenOnScreen(); + progress.hideOverlay(); } + } catch (err) { + if (cancelled) return; // user cancel already aborted the fetch + restored live + // A failure can predate the controller install, so the effect wouldn't + // fire: tear down here, best-effort so its own throw can't bury `err`. + if (!inPlace) { + try { + timeline.reset(); + handle.timeline.uninstallScrubController(); + handle.timeline.setStreetsTransparent(false); + handle.timeline.setFootprintsTransparent(false); + } catch { + /* teardown failed; surfacing err + hiding the overlay below is what matters */ + } + } + if (overlay) progress.hideOverlay(); + report.markError(err); } - if (overlay) hideLoadingOverlay(); - markError(err); } -} -/** Where the scrubber lands. An unknown sha falls through to the present rather - * than erroring: the union cap can drop one, and a link can go stale. */ -function scrubTarget(bundle: TimelineBundle, commit: string | undefined, inPlace: boolean): number { - if (commit) { - const idx = bundle.commits.findIndex((c) => c.sha === commit); - if (idx >= 0) return idx; + /** Where the scrubber lands. An unknown sha falls through to the present rather + * than erroring: the union cap can drop one, and a link can go stale. */ + function scrubTarget( + bundle: TimelineBundle, + commit: string | undefined, + inPlace: boolean + ): number { + if (commit) { + const idx = bundle.commits.findIndex((c) => c.sha === commit); + if (idx >= 0) return idx; + } + return inPlace ? timeline.scrubPos.peek() : timeline.scrubMax.peek(); } - return inPlace ? SCRUB_POS.peek() : SCRUB_MAX.peek(); -} - -/** Enter Timeline for the source already open. */ -export function loadTimelineScene( - opts: { - inPlace?: boolean; - noCache?: boolean; - overlay?: boolean; - commit?: string; - } = {} -): Promise { - const cur = CURRENT_SOURCE.peek(); - if (!cur) return Promise.resolve(); - return loadTimelineSource({ src: cur.src, branch: cur.branch, ...opts }); -} - -// A refresh in Timeline → refetch the bundle, not a HEAD re-scan. A callback -// because a direct import was a cycle; registered before the mode can turn on. -setTimelineRefreshHandler((opts) => loadTimelineScene({ inPlace: true, ...opts })); -// The boot path, for the same reason. `?mode=timeline` loads the bundle and -// nothing else: Live's scan is a different view's load, and it can wait for one. -setTimelineBootHandler((payload) => loadTimelineSource(payload)); + /** Enter Timeline for the source already open. */ + function loadTimelineScene( + opts: { + inPlace?: boolean; + noCache?: boolean; + overlay?: boolean; + commit?: string; + } = {} + ): Promise { + const cur = source.current.peek(); + if (!cur) return Promise.resolve(); + return loadTimelineSource({ src: cur.src, branch: cur.branch, ...opts }); + } -// Enter Timeline if it isn't on, then scrub to the commit. No-op if the sha -// isn't in the bundle (the union cap can drop one) or the mode failed to engage. -export async function viewCommitInTimeline(sha: string): Promise { - if (!TIMELINE_MODE.peek()) { - await loadTimelineScene({ commit: sha }); - return; // the load rests on the commit itself; a failure is surfaced already + // Enter Timeline if it isn't on, then scrub to the commit. No-op if the sha + // isn't in the bundle (the union cap can drop one) or the mode failed to engage. + async function viewCommitInTimeline(sha: string): Promise { + if (!timeline.mode.peek()) { + await loadTimelineScene({ commit: sha }); + return; // the load rests on the commit itself; a failure is surfaced already + } + const bundle = timeline.bundle.peek(); + if (!bundle) return; + const idx = bundle.commits.findIndex((c) => c.sha === sha); + if (idx >= 0) timeline.setScrubPos(idx); } - const bundle = TIMELINE_BUNDLE.peek(); - if (!bundle) return; - const idx = bundle.commits.findIndex((c) => c.sha === sha); - if (idx >= 0) setScrubPos(idx); -} -// Re-pack from the warm bundle, holding SCRUB_POS, so a settings Save in -// Timeline stays in Timeline instead of dropping to live HEAD. -export async function reapplyTimelineScene(): Promise { - const handle = SCENE_HANDLE.peek(); - const bundle = TIMELINE_BUNDLE.peek(); - if (!handle || !bundle) return; - const timelines = buildPathTimelines(bundle); - await handle.applyManifest(bundle.unionManifest as unknown as Manifest); - handle.timeline.setStreetsTransparent(true); - handle.timeline.setFootprintsTransparent(true); - handle.timeline.installScrubController(timelines, bundle.commitLineRanges); -} + // Re-pack from the warm bundle, holding SCRUB_POS, so a settings Save in + // Timeline stays in Timeline instead of dropping to live HEAD. + async function reapplyTimelineScene(): Promise { + const handle = city.peek(); + const bundle = timeline.bundle.peek(); + if (!handle || !bundle) return; + const timelines = buildPathTimelines(bundle); + await handle.applyManifest(bundle.unionManifest as unknown as Manifest); + handle.timeline.setStreetsTransparent(true); + handle.timeline.setFootprintsTransparent(true); + handle.timeline.installScrubController(timelines, bundle.commitLineRanges); + } -// Scene-free: the city-layer effect (city/index.ts) reacts to TIMELINE_MODE and does the scene teardown. -export function teardownTimelineMode(): void { - resetTimelineMode(); -} + // Scene-free: the city-layer effect (city/index.ts) reacts to TIMELINE_MODE and does the scene teardown. + function teardownTimelineMode(): void { + timeline.reset(); + } -export function exitTimelineMode(): void { - const cur = CURRENT_SOURCE.peek(); - const scrubPos = SCRUB_POS.peek(); // remember where the scrubber was - teardownTimelineMode(); - if (!cur) return; - // Cancelling the reload re-enters Timeline where you were rather than dumping - // you on the switcher. Registered first, so loadSource's overlay keeps it. - setLoadingCancel(() => { - cancelLoad(); - void loadTimelineScene().then(() => { - if (TIMELINE_MODE.peek()) setScrubPos(scrubPos); + function exitTimelineMode(): void { + const cur = source.current.peek(); + const scrubPos = timeline.scrubPos.peek(); // remember where the scrubber was + teardownTimelineMode(); + if (!cur) return; + // Cancelling the reload re-enters Timeline where you were rather than dumping + // you on the switcher. Registered first, so loadSource's overlay keeps it. + progress.setCancel(() => { + session.load.cancel(); + void loadTimelineScene().then(() => { + if (timeline.mode.peek()) timeline.setScrubPos(scrubPos); + }); }); - }); - void loadSource({ src: cur.src, branch: cur.branch }); + void session.load.loadSource({ src: cur.src, branch: cur.branch }); + } + + return { + loadSource: loadTimelineSource, + loadScene: loadTimelineScene, + viewCommit: viewCommitInTimeline, + reapply: reapplyTimelineScene, + teardown: teardownTimelineMode, + exit: exitTimelineMode, + }; } diff --git a/app/src/main.tsx b/app/src/main.tsx index 09739652..377963e1 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -18,9 +18,3 @@ const mount = document.getElementById('app'); if (mount) { render(, mount); } - -// Debug-only README screenshot capture: only when opened with ?shot=. -// Dynamically imported so the harness never ships in a normal session. -if (new URLSearchParams(window.location.search).has('shot')) { - void import('@/city/capture/captureHarness').then((m) => m.initCaptureHarness()); -} diff --git a/app/src/router/urlBinding.ts b/app/src/router/urlBinding.ts new file mode 100644 index 00000000..a5a022db --- /dev/null +++ b/app/src/router/urlBinding.ts @@ -0,0 +1,58 @@ +// router/urlBinding.ts — the URL as an adapter over ONE project: it reflects +// that session's source into ?src/?branch, loads whatever the address bar +// names, and binds the view params on top. Attached to the session the address +// bar is describing, which is exactly one of them — a side-by-side view would +// attach it to the focused column, or to none and say where it is another way. + +import { URL_PARAMS } from '@/constants/urlParams'; +import { VIEW_PARAMS } from '@/router/params'; +import { ROUTES } from '@/router/paths'; +import { navigate, hrefFor, ROUTE_SEARCH, ROUTE_PATH } from '@/router/location'; +import { effect } from '@preact/signals'; +import { attachViewUrlReactions } from '@/router/viewBinding'; +import type { ProjectSession } from '@/state/project/session'; + +/** Drop the load from the URL and go home: a cancel with nothing to fall back + * to must not leave a reload re-running what it called off. */ +export function clearSourceUrl(): void { + // Anything the app does not own (an ?utm_source, say) rides along home: only + // the params describing the load that was called off are dropped. + const params = new URLSearchParams(ROUTE_SEARCH.peek()); + for (const key of [...Object.values(URL_PARAMS), ...Object.values(VIEW_PARAMS)]) { + params.delete(key); + } + navigate(hrefFor(ROUTES.HOME, params), { replace: true }); +} + +/** Reflect the applied source so reload/share reopens it, moving to /city if + * the load began at home. */ +function attachSourceReflection(session: ProjectSession): () => void { + return effect(() => { + const cur = session.source.current.value; + if (!cur) return; + const params = new URLSearchParams(ROUTE_SEARCH.peek()); + // A different project than the URL was describing: its mode, scrub commit + // and selection belong to the one that just left. + const had = params.get(URL_PARAMS.SRC); + if (had && !session.source.isOpen(had, params.get(URL_PARAMS.BRANCH))) { + for (const key of Object.values(VIEW_PARAMS)) params.delete(key); + } + params.set(URL_PARAMS.SRC, cur.src); + if (cur.branch) params.set(URL_PARAMS.BRANCH, cur.branch); + else params.delete(URL_PARAMS.BRANCH); + // From the switcher this is a place you went, so it pushes and Back returns + // to the list; already on /city (a re-scan, a deep link) is the same place. + const fromHome = ROUTE_PATH.peek() === ROUTES.HOME; + navigate(hrefFor(ROUTES.CITY, params), { replace: !fromHome }); + }); +} + +/** Point the address bar at `session`, both ways. Returns a dispose. */ +export function attachUrlBinding(session: ProjectSession): () => void { + const stops = [ + attachSourceReflection(session), + session.load.attachRouteLoad(), + attachViewUrlReactions(session), + ]; + return () => stops.forEach((stop) => stop()); +} diff --git a/app/src/router/viewBinding.ts b/app/src/router/viewBinding.ts index 6c403a39..593b4434 100644 --- a/app/src/router/viewBinding.ts +++ b/app/src/router/viewBinding.ts @@ -9,129 +9,127 @@ import { VIEW_PARAMS, TIMELINE_MODE_PARAM } from '@/router/params'; import { setRouteParams, ROUTE_PARAMS, ROUTE_PATH, type NavigateOptions } from './location'; import { parseSelection, selectionParam } from './viewParams'; import { ROUTES } from './paths'; -import { CURRENT_SOURCE } from '@/state/stores/source'; -import { CITY_ON_SCREEN } from '@/state/stores/progress'; -import { goToPath, goToCommit, clearSelection } from '@/city/sceneHandle'; +import type { ProjectSession } from '@/state/project/session'; import { FocusMode } from '@/city/render/cameraRig'; -import { - TIMELINE_MODE, - TIMELINE_BUNDLE, - SETTLED_COMMIT, - SETTLED_POS, - SCRUB_MAX, -} from '@/state/stores/timeline'; -import { PICKER_SELECTION_KEY } from '@/city/interaction/picker'; -import { loadTimelineScene, exitTimelineMode, viewCommitInTimeline } from '@/hooks/useTimelineMode'; import { NodeKind } from '@/types'; import type { PickerSelectionKey } from '@/types'; const REPLACE: NavigateOptions = { replace: true }; -// ── Encoding ───────────────────────────────────────────────────────── - -/** The sha the scrubber rests on, or null at the present — so a link that - * means "now" still means it once the branch has moved on. */ -function settledCommitSha(): string | null { - if (SETTLED_POS.value >= SCRUB_MAX.value) return null; - return TIMELINE_BUNDLE.value?.commits[SETTLED_COMMIT.value]?.sha ?? null; -} - -function setOrDelete(params: URLSearchParams, key: string, value: string | null): void { - if (value === null) params.delete(key); - else params.set(key, value); -} - -// ── Reflection ─────────────────────────────────────────────────────── - -function reflectViewToUrl(): void { - const timeline = TIMELINE_MODE.value; - const selection = selectionParam(PICKER_SELECTION_KEY.value); - const commit = timeline ? settledCommitSha() : null; - // Replace, always: none of these is a place the user asked to go, and a drag - // would otherwise bury their own history under a hundred entries. - setRouteParams((params) => { - setOrDelete(params, VIEW_PARAMS.MODE, timeline ? TIMELINE_MODE_PARAM : null); - // A scrub position only means something in Timeline: back in Live you are at - // HEAD, so the commit leaves with the mode. - setOrDelete(params, VIEW_PARAMS.COMMIT, commit); - setOrDelete(params, VIEW_PARAMS.SELECTION, selection); - }, REPLACE); -} - -// ── Follow ─────────────────────────────────────────────────────────── - -/** Every kind restores alike: the same go-to command a list row sends, in the - * focus mode that centres the node without turning the camera off its angle. */ -function applySelection(selection: PickerSelectionKey | null): void { - if (!selection) clearSelection(); - else if (selection.kind === NodeKind.Commit) goToCommit(selection.sha, FocusMode.Recenter); - else goToPath(selection.path, FocusMode.Recenter); -} - -/** Put the view the URL describes on screen. Reads the live view untracked and - * acts only on what the URL newly asks for: the reflection writes this URL. */ -function installViewFollow(followed: Signal): () => void { - // What the URL asked for last pass. The actions below are async, so re-acting - // on a request already carried out re-issues it forever (see the test). - let asked: { mode: boolean; commit: string | null; selection: string | null } | null = null; - - return effect(() => { - const params = ROUTE_PARAMS.value; - const onCity = ROUTE_PATH.value === ROUTES.CITY; - const source = CURRENT_SOURCE.value; - const built = CITY_ON_SCREEN.value; - // Nothing to select in, and no bundle to scrub, until there is a city. - if (!onCity || !source || !built) return; - - const wantTimeline = params.get(VIEW_PARAMS.MODE) === TIMELINE_MODE_PARAM; - const wantCommit = params.get(VIEW_PARAMS.COMMIT); - const wantSelection = params.get(VIEW_PARAMS.SELECTION); - // settledCommitSha reads signals this effect's own action writes, so the - // whole comparison is untracked: tracked, it wakes on its own work. - const [modeOff, commitOff, selectionOff] = untracked(() => [ - wantTimeline !== TIMELINE_MODE.peek(), - wantTimeline && !!wantCommit && wantCommit !== settledCommitSha(), - wantSelection !== selectionParam(PICKER_SELECTION_KEY.peek()), - ]); - - // A dimension is acted on when the view is off it AND the URL is asking for - // something it has not already been asked for. - const fresh = asked; - asked = { mode: wantTimeline, commit: wantCommit, selection: wantSelection }; - const modeDiffers = modeOff && (fresh === null || fresh.mode !== wantTimeline); - const commitDiffers = commitOff && (fresh === null || fresh.commit !== wantCommit); - const selectionDiffers = selectionOff && (fresh === null || fresh.selection !== wantSelection); - - // The common case, and the gate must open NOW rather than a microtask - // later, or the view it protects has moved on by the time it does. - if (!modeDiffers && !commitDiffers && !selectionDiffers) { - followed.value = true; - return; - } - - // Out of the tracking scope: everything below writes signals this reads. - queueMicrotask(() => { - if (modeDiffers) { - if (wantTimeline) void loadTimelineScene({ commit: wantCommit ?? undefined }); - else exitTimelineMode(); - } else if (commitDiffers && wantCommit) { - void viewCommitInTimeline(wantCommit); +/** Bind one project's view — the mode, the commit the scrubber rests on, and + * the selection — to the URL, in both directions. Attached for the session the + * address bar is describing; a second one simply is not. */ +export function attachViewUrlReactions(session: ProjectSession): () => void { + const { source, progress, timeline, commands, city } = session; + + // ── Encoding ───────────────────────────────────────────────────────── + + /** The sha the scrubber rests on, or null at the present — so a link that + * means "now" still means it once the branch has moved on. */ + function settledCommitSha(): string | null { + if (timeline.settledPos.value >= timeline.scrubMax.value) return null; + return timeline.bundle.value?.commits[timeline.settledCommit.value]?.sha ?? null; + } + + function setOrDelete(params: URLSearchParams, key: string, value: string | null): void { + if (value === null) params.delete(key); + else params.set(key, value); + } + + // ── Reflection ─────────────────────────────────────────────────────── + + function reflectViewToUrl(): void { + const scrubbing = timeline.mode.value; + const selection = selectionParam(city.value?.picker.selectionKey.value ?? null); + const commit = scrubbing ? settledCommitSha() : null; + // Replace, always: none of these is a place the user asked to go, and a drag + // would otherwise bury their own history under a hundred entries. + setRouteParams((params) => { + setOrDelete(params, VIEW_PARAMS.MODE, scrubbing ? TIMELINE_MODE_PARAM : null); + // A scrub position only means something in Timeline: back in Live you are at + // HEAD, so the commit leaves with the mode. + setOrDelete(params, VIEW_PARAMS.COMMIT, commit); + setOrDelete(params, VIEW_PARAMS.SELECTION, selection); + }, REPLACE); + } + + // ── Follow ─────────────────────────────────────────────────────────── + + /** Every kind restores alike: the same go-to command a list row sends, in the + * focus mode that centres the node without turning the camera off its angle. */ + function applySelection(selection: PickerSelectionKey | null): void { + if (!selection) commands.clearSelection(); + else if (selection.kind === NodeKind.Commit) + commands.goToCommit(selection.sha, FocusMode.Recenter); + else commands.goToPath(selection.path, FocusMode.Recenter); + } + + /** Put the view the URL describes on screen. Reads the live view untracked and + * acts only on what the URL newly asks for: the reflection writes this URL. */ + function installViewFollow(followed: Signal): () => void { + // What the URL asked for last pass. The actions below are async, so re-acting + // on a request already carried out re-issues it forever (see the test). + let asked: { mode: boolean; commit: string | null; selection: string | null } | null = null; + + return effect(() => { + const params = ROUTE_PARAMS.value; + const onCity = ROUTE_PATH.value === ROUTES.CITY; + const opened = source.current.value; + const built = progress.cityOnScreen.value; + // Nothing to select in, and no bundle to scrub, until there is a city. + if (!onCity || !opened || !built) return; + + const wantTimeline = params.get(VIEW_PARAMS.MODE) === TIMELINE_MODE_PARAM; + const wantCommit = params.get(VIEW_PARAMS.COMMIT); + const wantSelection = params.get(VIEW_PARAMS.SELECTION); + // settledCommitSha reads signals this effect's own action writes, so the + // whole comparison is untracked: tracked, it wakes on its own work. + const [modeOff, commitOff, selectionOff] = untracked(() => [ + wantTimeline !== timeline.mode.peek(), + wantTimeline && !!wantCommit && wantCommit !== settledCommitSha(), + wantSelection !== selectionParam(city.peek()?.picker.selectionKey.peek() ?? null), + ]); + + // A dimension is acted on when the view is off it AND the URL is asking for + // something it has not already been asked for. + const fresh = asked; + asked = { mode: wantTimeline, commit: wantCommit, selection: wantSelection }; + const modeDiffers = modeOff && (fresh === null || fresh.mode !== wantTimeline); + const commitDiffers = commitOff && (fresh === null || fresh.commit !== wantCommit); + const selectionDiffers = + selectionOff && (fresh === null || fresh.selection !== wantSelection); + + // The common case, and the gate must open NOW rather than a microtask + // later, or the view it protects has moved on by the time it does. + if (!modeDiffers && !commitDiffers && !selectionDiffers) { + followed.value = true; + return; } - if (selectionDiffers) applySelection(parseSelection(wantSelection)); - followed.value = true; + + // Out of the tracking scope: everything below writes signals this reads. + queueMicrotask(() => { + if (modeDiffers) { + if (wantTimeline) + void session.timelineMode.loadScene({ commit: wantCommit ?? undefined }); + else session.timelineMode.exit(); + } else if (commitDiffers && wantCommit) { + void session.timelineMode.viewCommit(wantCommit); + } + if (selectionDiffers) applySelection(parseSelection(wantSelection)); + followed.value = true; + }); }); - }); -} + } + + /** Mount the URL⇄view binding. Returns a dispose. */ -/** Mount the URL⇄view binding. Returns a dispose. */ -export function attachViewUrlReactions(): () => void { // The reflection waits for the follow's first pass, or it would describe an // empty view over the URL it is about to be told to restore. const followed = signal(false); const stopFollow = installViewFollow(followed); const stopReflect = effect(() => { // Like the ?src reflection: an unloaded page has no view to describe. - if (!CURRENT_SOURCE.value || !followed.value) return; + if (!source.current.value || !followed.value) return; reflectViewToUrl(); }); return () => { diff --git a/app/src/state/project/context.tsx b/app/src/state/project/context.tsx new file mode 100644 index 00000000..7ea6a73d --- /dev/null +++ b/app/src/state/project/context.tsx @@ -0,0 +1,31 @@ +// state/project/context.tsx — which project the chrome around a city is bound +// to. Provided, not global: wrap the app in one session and the chrome reads +// that one, wrap each column of a side-by-side view in its own and the same +// components read theirs. The city itself takes its config directly and needs +// none of this. + +import { createContext } from 'preact'; +import { useContext } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import type { ProjectSession } from './session'; + +const ProjectContext = createContext(null); + +export function ProjectProvider({ + session, + children, +}: { + session: ProjectSession; + children: ComponentChildren; +}) { + return {children}; +} + +/** The project this part of the tree belongs to. Throws rather than falling + * back to a global: a component reading the wrong project is the bug this + * whole shape exists to make impossible. */ +export function useProject(): ProjectSession { + const session = useContext(ProjectContext); + if (!session) throw new Error('useProject: no above this component'); + return session; +} diff --git a/app/src/state/project/session.ts b/app/src/state/project/session.ts new file mode 100644 index 00000000..438aa18e --- /dev/null +++ b/app/src/state/project/session.ts @@ -0,0 +1,61 @@ +// state/project/session.ts — one open project, whole: which repo, its manifest, +// how far its load and build have got, the history it is being scrubbed +// through, and the city rendering it. Nothing in here is app-wide, so a second +// session is a second project with nothing shared — which is what lets two +// cities sit side by side without hearing each other. + +import { signal, type Signal } from '@preact/signals'; +import { createManifestStore, type ManifestStore } from '@/state/stores/manifest'; +import { createSourceStore, type SourceStore } from '@/state/stores/source'; +import { createProgressStore, type ProgressStore } from '@/state/stores/progress'; +import { createTimelineStore, type TimelineStore } from '@/state/stores/timeline'; +import { createCityCommands, type CityCommands } from '@/city/sceneHandle'; +import { createProjectLoader, type ProjectLoader } from '@/hooks/useManifestSource'; +import { createTimelineController, type TimelineController } from '@/hooks/useTimelineMode'; +import type { City } from '@/city/types'; + +export interface ProjectSession { + source: SourceStore; + manifest: ManifestStore; + progress: ProgressStore; + timeline: TimelineStore; + /** The city rendering this project, while one is mounted. The chrome reaches + * through here to command it; a session with no city is still a session. */ + city: Signal; + /** The verbs the chrome sends that city. */ + commands: CityCommands; + /** Fetching this project: the scan, its cancel, and the live-update poll. */ + load: ProjectLoader; + /** Timeline for this project: entering, scrubbing, and leaving. */ + timelineMode: TimelineController; + dispose(): void; +} + +export function createProjectSession(): ProjectSession { + // Order is the dependency graph: the manifest is what a source describes, + // and both are what progress and history are measured against. + const manifest = createManifestStore(); + const source = createSourceStore({ manifest }); + const progress = createProgressStore({ manifest, source }); + const timeline = createTimelineStore({ manifest }); + + // The three below take the session they belong to, so a call can reach the + // rest of it: filled in on the object rather than passed around, which is + // what a scan needing the timeline (and vice versa) used to inject by hand. + const session = { + source, + manifest, + progress, + timeline, + city: signal(null), + } as ProjectSession; + + session.commands = createCityCommands(session); + session.load = createProjectLoader(session); + session.timelineMode = createTimelineController(session); + session.dispose = () => { + session.load.dispose(); + timeline.dispose(); + }; + return session; +} diff --git a/app/src/state/settings/fields/updates.ts b/app/src/state/settings/fields/updates.ts index 97ab2e4e..294a0cd0 100644 --- a/app/src/state/settings/fields/updates.ts +++ b/app/src/state/settings/fields/updates.ts @@ -2,8 +2,7 @@ // every POLL_SECONDS and re-render in place when its content_signature moves. // On by default so the city tracks edits out of the box. manifestPoll owns the // hard bounds POLL_SECONDS is clamped to. -import { computed } from '@preact/signals'; -import { CURRENT_SOURCE_IS_LOCAL } from '@/state/stores/source'; +import type { SourceStore } from '@/state/stores/source'; import { settingSignal, markAutosave, @@ -40,6 +39,8 @@ export type LiveUpdatesConfig = ConfigOf; /** The toggle AND a local source. Remote is excluded on cost: ensure_clone * fetches on every open, so polling one means a git fetch every few seconds. */ -export const LIVE_UPDATES_ACTIVE = computed( - () => LIVE_UPDATES.value.ENABLED && CURRENT_SOURCE_IS_LOCAL.value -); +/** Whether this project is being watched for edits: the setting is app-wide, + * but only a working tree on disk can change under the app. */ +export function liveUpdatesActive(source: SourceStore): boolean { + return LIVE_UPDATES.value.ENABLED && source.isLocal.value; +} diff --git a/app/src/state/settings/transfer.ts b/app/src/state/settings/transfer.ts index 7b063e39..57d75042 100644 --- a/app/src/state/settings/transfer.ts +++ b/app/src/state/settings/transfer.ts @@ -5,7 +5,7 @@ import { batch } from '@preact/signals'; import { getStoreName, getDefault } from '@/state/persist'; -import { CURRENT_SOURCE, activeExcludePathsFor, setExcludesFor } from '@/state/stores/source'; +import { activeExcludePathsFor, setExcludesFor, type SourceStore } from '@/state/stores/source'; import { deepEqual } from '@/utils/deep'; import { coerceFieldValue, type SettingStore } from './schema'; import { dropDrafts } from './drafts'; @@ -134,35 +134,42 @@ function readExcludes(raw: unknown): TransferExcludes | null { }; } -export const EXCLUDES_PART: TransferPart = { - key: 'EXCLUDES', - family: TransferFamily.Scan, - read: () => { - const current = CURRENT_SOURCE.peek(); - // An empty list is a real answer, not a missing one: "I hide nothing here". - return { - ...(current ? { src: current.src } : {}), - ...(current?.branch ? { branch: current.branch } : {}), - paths: current ? activeExcludePathsFor(current.src) : [], - } satisfies TransferExcludes; - }, - write: (value) => { - // Onto the repo the list is FOR, which is the exporter's, not whichever - // city is on screen. Nothing here opens it: it is there when you next go. - const excludes = readExcludes(value); - if (excludes?.src) setExcludesFor(excludes.src, excludes.paths); - return []; - }, - differsFrom: (value) => { - const excludes = readExcludes(value); - // Whose list is being compared: the file's repo on the way in, the open one - // when asking whether this browser has anything to send. - const src = excludes?.src ?? CURRENT_SOURCE.peek()?.src; - if (!src) return false; - const next = [...new Set(excludes?.paths ?? [])].sort(); - return !deepEqual(activeExcludePathsFor(src), next); - }, -}; +/** The hidden-paths part, for one project: whose list travels depends on which + * repo is open, so this is made per session rather than being a module const. */ +/** The one key the excludes travel under, for readers that have no part. */ +export const EXCLUDES_KEY = 'EXCLUDES'; + +export function excludesPart(source: SourceStore): TransferPart { + return { + key: EXCLUDES_KEY, + family: TransferFamily.Scan, + read: () => { + const current = source.current.peek(); + // An empty list is a real answer, not a missing one: "I hide nothing here". + return { + ...(current ? { src: current.src } : {}), + ...(current?.branch ? { branch: current.branch } : {}), + paths: current ? activeExcludePathsFor(current.src) : [], + } satisfies TransferExcludes; + }, + write: (value) => { + // Onto the repo the list is FOR, which is the exporter's, not whichever + // city is on screen. Nothing here opens it: it is there when you next go. + const excludes = readExcludes(value); + if (excludes?.src) setExcludesFor(excludes.src, excludes.paths); + return []; + }, + differsFrom: (value) => { + const excludes = readExcludes(value); + // Whose list is being compared: the file's repo on the way in, the open one + // when asking whether this browser has anything to send. + const src = excludes?.src ?? source.current.peek()?.src; + if (!src) return false; + const next = [...new Set(excludes?.paths ?? [])].sort(); + return !deepEqual(activeExcludePathsFor(src), next); + }, + }; +} // ── Export ───────────────────────────────────────────────────────────────── @@ -233,7 +240,7 @@ export function parseSettingsFile( /** Which repo a file's hidden paths will be filed under, for the UI to name. * Null when it carries none, or carries a list that names no repo. */ export function excludesOrigin(parsed: ParsedSettingsFile): TransferExcludes | null { - const raw = parsed.file[TransferFamily.Scan]?.[EXCLUDES_PART.key]; + const raw = parsed.file[TransferFamily.Scan]?.[EXCLUDES_KEY]; const excludes = readExcludes(raw); return excludes?.src ? excludes : null; } diff --git a/app/src/state/stores/manifest.ts b/app/src/state/stores/manifest.ts index a7dfda37..62e8dc4b 100644 --- a/app/src/state/stores/manifest.ts +++ b/app/src/state/stores/manifest.ts @@ -1,19 +1,29 @@ -// state/stores/manifest.ts — the manifest of the project you opened, at HEAD. -// The one the fetch layer writes and everything else reads. Two neighbours -// answer the same question differently: `scrub.ts` for what a Timeline position -// implies, `progress.ts` for how far the world built from this has got. +// state/stores/manifest.ts — the manifest of one open project, at HEAD. Its +// session's fetch layer writes it and everything in that session reads it. Two +// neighbours answer the same question differently: `timeline.ts` for what a +// scrub position implies, `progress.ts` for how far its build has got. -import { signal } from '@preact/signals'; +import { signal, type Signal } from '@preact/signals'; import type { Manifest, DirNode } from '@/types'; // The union spans a final Manifest, a bare DirNode, and the loose skeleton the // stream emits before it is fully typed. export type ManifestValue = Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null; -export const MANIFEST = signal(null); +export interface ManifestStore { + /** What this project is, as last fetched. */ + readonly current: Signal; + /** Set it: skeleton, final, live-update or a rollback. The fetch layer is the + * single writer; everything else reads. */ + set(m: ManifestValue): void; +} -/** Set the current manifest: skeleton, final, live-update or a rollback. The - * fetch layer is the single writer; everything else reads MANIFEST. */ -export function setManifest(m: ManifestValue): void { - MANIFEST.value = m ?? null; +export function createManifestStore(): ManifestStore { + const current = signal(null); + return { + current, + set: (m) => { + current.value = m ?? null; + }, + }; } diff --git a/app/src/state/stores/progress.ts b/app/src/state/stores/progress.ts index 0baae353..7418c08e 100644 --- a/app/src/state/stores/progress.ts +++ b/app/src/state/stores/progress.ts @@ -1,9 +1,10 @@ -// state/stores/progress.ts — how far along the thing you are waiting for is: -// what the SERVER reports, what the BUILD is doing, and what the OVERLAY shows -// on top of both. The overlay is a reduction over the other two (a running max -// over rows, the last tail each saw), which is why it needs the driver below. +// state/stores/progress.ts — how far along the thing you are waiting for is, +// for ONE project: what the server reports, what the build is doing, and what +// the overlay shows on top of both. The overlay is a reduction over the other +// two (a running max over rows, the last tail each saw), which is why it needs +// the driver at the bottom. -import { signal, computed, effect } from '@preact/signals'; +import { signal, computed, effect, type ReadonlySignal, type Signal } from '@preact/signals'; import { SourceKind } from '@/utils/sources'; import { ScanPhase, CloneStage } from '@/api/manifest'; import type { Manifest } from '@/types'; @@ -18,8 +19,8 @@ import { stepForPhase, transferTail, } from '@/constants/progress'; -import { MANIFEST, type ManifestValue } from './manifest'; -import { SOURCE_INFO } from './source'; +import type { ManifestStore, ManifestValue } from './manifest'; +import type { SourceStore } from './source'; import type { LoadingOverlayShowOpts, LoadingOverlayState } from '@/types/ui'; // ── What the server is doing ───────────────────────────────────────── @@ -49,9 +50,6 @@ export interface ScanProgress { appliedPending?: Manifest['pending']; } -/** Non-null while a source is actively loading; null when idle/done. */ -export const SCAN_PROGRESS = signal(null); - // ── What the build is doing ────────────────────────────────────────── /** State of the most recent world rebuild. Decorating is the city already on @@ -66,114 +64,9 @@ export enum RebuildStatus { Error = 'error', } -export const REBUILD_STATUS = signal(RebuildStatus.Pending); - -/** The manifest the FINISHED city was built from, trees included: a consumer - * that aims the camera at a node needs that node to exist. */ -export const BUILT_MANIFEST = signal(null); - -/** Whether a finished city is on screen RIGHT NOW: set when one is presented, - * cleared when the canvas goes away. Not "has anything ever been built". */ -export const CITY_ON_SCREEN = computed(() => BUILT_MANIFEST.value !== null); - -/** Error message from the most recent failed rebuild; null when idle/success. */ -export const LAST_REBUILD_ERROR = signal(null); - -/** Progress beside "rebuilding…", for the one build nothing else reports: - * Timeline's no-overlay refetch. Every transition clears it. */ -export const REBUILD_DETAIL = signal(null); - -/** Epoch millis of the most recent finished apply, in whichever mode: a live - * scan or Timeline's history bundle both land here via markIdle. */ -export const LAST_UPDATED_AT = signal(0); - -/** Which stage the running build is on, null between builds. The one source - * behind both of its readouts (see state/loadingReactions.ts). */ -export const BUILD_PROGRESS = signal(null); - -// ── Status transitions (single owner of each state + its coupled writes) ── - -// Every rebuild path goes through these, so the status/error/timestamp set -// can't drift across the four call sites. markIdle ends every applyManifest. -export function markRebuilding(): void { - REBUILD_STATUS.value = RebuildStatus.Rebuilding; - REBUILD_DETAIL.value = null; - BUILD_PROGRESS.value = null; -} - -// Decoration is the build's last stage, not the end of it: the overlay stays up -// through the tree pass, and the trees land at the END of that stage. -export function markDecorating(): void { - REBUILD_STATUS.value = RebuildStatus.Decorating; - enterBuildStage(BuildStage.Decorate); -} - -/** The city is on screen. Set by the composer once the meshes exist and a frame - * carrying them has been presented — NOT when applyStructure returns. */ -export function markIdle(): void { - REBUILD_STATUS.value = RebuildStatus.Idle; - BUILT_MANIFEST.value = MANIFEST.peek(); - LAST_REBUILD_ERROR.value = null; - REBUILD_DETAIL.value = null; - BUILD_PROGRESS.value = null; - LAST_UPDATED_AT.value = Date.now(); -} - -/** The canvas went away (leaving `/city` unmounts it), so nothing is on screen: - * the remount's rebuild is a load with a world to wait for. */ -function markGone(): void { - REBUILD_STATUS.value = RebuildStatus.Pending; - BUILT_MANIFEST.value = null; - REBUILD_DETAIL.value = null; - BUILD_PROGRESS.value = null; -} - -export function markError(err: unknown): void { - REBUILD_STATUS.value = RebuildStatus.Error; - // Logged with the stack, where a developer can use it. The UI shows a generic - // line: the message names our internals and a user cannot act on it. - console.error('[codecity] city build failed', err); - LAST_REBUILD_ERROR.value = err instanceof Error ? err.message : String(err); - REBUILD_DETAIL.value = null; - BUILD_PROGRESS.value = null; -} - -/** How far along the rebuild already announced by markRebuilding is. */ -export function setRebuildDetail(detail: string | null): void { - REBUILD_DETAIL.value = detail; -} - -// ── Build stages ───────────────────────────────────────────────────── - -// The build's own transitions, owned here for the same reason as the status -// ones above. The plan is per build: only what runs is an honest denominator. - -/** Open a build on the first of the stages it is going to run. */ -export function beginBuild(stages: readonly BuildStage[]): void { - BUILD_PROGRESS.value = { stages, index: 0, percent: null }; -} - -/** Advance to a stage of the declared plan. A stage the plan didn't list is - * ignored rather than appended: the denominator was already shown. */ -export function enterBuildStage(stage: BuildStage): void { - const prev = BUILD_PROGRESS.peek(); - if (!prev) return; - const index = prev.stages.indexOf(stage); - if (index < 0 || index === prev.index) return; - BUILD_PROGRESS.value = { ...prev, index, percent: null }; -} - -/** Report progress within the current stage, for one that can measure itself. */ -export function setBuildStagePercent(percent: number): void { - const prev = BUILD_PROGRESS.peek(); - if (!prev || prev.percent === percent) return; - BUILD_PROGRESS.value = { ...prev, percent }; -} - -// ── Who reports the world's build ──────────────────────────────────── - -/** The status writes a city makes as it assembles. Two cities build over one - * session, and only the project you opened is the world these describe. */ +/** The status writes a city makes as it assembles. A city is handed one of + * these, which is how its build reaches the readouts describing it — or does + * not, when nobody is waiting for that city. */ export interface BuildReporter { beginBuild(stages: readonly BuildStage[]): void; enterBuildStage(stage: BuildStage): void; @@ -188,21 +81,8 @@ export interface BuildReporter { status(): RebuildStatus; } -/** The project you opened: its build is the wait every readout is about. */ -export const OPENED_PROJECT_REPORTER: BuildReporter = { - beginBuild, - enterBuildStage, - setBuildStagePercent, - markRebuilding, - markDecorating, - markIdle, - markError, - markGone, - status: () => REBUILD_STATUS.peek(), -}; - -/** Scenery: nobody is waiting for it, and a finished wallpaper claiming the - * world is on screen outlives the canvas it was drawn on. */ +/** Scenery: nobody is waiting for it, and a finished wallpaper claiming a city + * is on screen outlives the canvas it was drawn on. */ export const SILENT_BUILD_REPORTER: BuildReporter = { beginBuild: () => {}, enterBuildStage: () => {}, @@ -211,159 +91,306 @@ export const SILENT_BUILD_REPORTER: BuildReporter = { markDecorating: () => {}, markIdle: () => {}, markGone: () => {}, - // Logged, never surfaced: a city nobody is waiting for has no readout to - // fail in, and a silent throw is how a broken wallpaper goes unnoticed. + // Logged, never surfaced: a city nobody is waiting for has no readout to fail + // in, and a silent throw is how a broken wallpaper goes unnoticed. markError: (err) => console.error('[codecity] scenery build failed', err), status: () => RebuildStatus.Pending, }; -// ── What the overlay shows ─────────────────────────────────────────── - -/** Repo name in the loading overlay's header, shown before the manifest lands. - * Overlay-owned: showLoadingOverlay/hideLoadingOverlay control its lifetime. */ -export const PENDING_SOURCE_LABEL = signal(null); - -export const LOADING_OVERLAY = signal({ - visible: false, - showOpts: null, - activeStep: null, - stepTails: {}, -}); - -// A load that can be backed out of registers its own abort; null falls back to -// the App default. -export const LOADING_CANCEL = signal<(() => void) | null>(null); - -// peek, not value: these are called from inside other effects, and tracking the -// prior state would subscribe an effect to a signal it goes on to write. - -// Omitting onCancel leaves any registered handler in place, so a caller can -// pre-register one before the reaction shows the overlay. -export function showLoadingOverlay( - opts: LoadingOverlayShowOpts, - onCancel?: (() => void) | null -): void { - LOADING_OVERLAY.value = { - visible: true, - showOpts: opts, - activeStep: firstStepFor(opts.steps ?? LOADING_STEPS, opts.kind), - stepTails: {}, - }; - if (onCancel !== undefined) LOADING_CANCEL.value = onCancel; -} - -export function setLoadingCancel(onCancel: (() => void) | null): void { - LOADING_CANCEL.value = onCancel; +export interface ProgressStore { + /** Non-null while this project is actively loading; null when idle/done. */ + readonly scan: Signal; + readonly rebuildStatus: Signal; + /** The manifest the FINISHED city was built from, trees included: a consumer + * that aims the camera at a node needs that node to exist. */ + readonly builtManifest: Signal; + /** Whether a finished city is on screen RIGHT NOW: set when one is presented, + * cleared when the canvas goes away. Not "has anything ever been built". */ + readonly cityOnScreen: ReadonlySignal; + /** Error message from the most recent failed rebuild; null when idle/success. */ + readonly lastError: Signal; + /** Progress beside "rebuilding…", for the one build nothing else reports: + * Timeline's no-overlay refetch. Every transition clears it. */ + readonly detail: Signal; + /** Epoch millis of the most recent finished apply, in whichever mode. */ + readonly lastUpdatedAt: Signal; + /** Which stage the running build is on, null between builds. */ + readonly buildProgress: Signal; + /** This project's city writes its status here. */ + readonly reporter: BuildReporter; + /** How far along the rebuild already announced by markRebuilding is. */ + setDetail(detail: string | null): void; + + // ── The overlay over this project ── + readonly overlay: Signal; + /** Repo name in the overlay's header, shown before the manifest lands. */ + readonly pendingLabel: Signal; + /** A load that can be backed out of registers its own abort; null falls back + * to the view's default. */ + readonly cancel: Signal<(() => void) | null>; + showOverlay(opts: LoadingOverlayShowOpts, onCancel?: (() => void) | null): void; + hideOverlay(): void; + setStep(step: LoadingStep): void; + setStepTail(step: LoadingStep, tail: string | null): void; + setCancel(onCancel: (() => void) | null): void; + /** Drive the overlay off this project's scan + build. Returns a dispose. */ + attachOverlayDriver(): () => void; } -export function hideLoadingOverlay(): void { - LOADING_OVERLAY.value = { ...LOADING_OVERLAY.peek(), visible: false }; - LOADING_CANCEL.value = null; - // The header belongs to the overlay, so it clears here rather than at each - // call site: one that forgets leaves a stale label over the next load. - PENDING_SOURCE_LABEL.value = null; -} - -export function setLoadingStep(step: LoadingStep): void { - const prev = LOADING_OVERLAY.peek(); - if (prev.activeStep === step) return; - LOADING_OVERLAY.value = { ...prev, activeStep: step }; -} - -export function setLoadingStepTail(step: LoadingStep, tail: string | null): void { - const prev = LOADING_OVERLAY.peek(); - LOADING_OVERLAY.value = { - ...prev, - stepTails: { ...prev.stepTails, [step]: tail }, +export function createProgressStore({ + manifest, + source, +}: { + manifest: ManifestStore; + source: SourceStore; +}): ProgressStore { + const scan = signal(null); + const rebuildStatus = signal(RebuildStatus.Pending); + const builtManifest = signal(null); + const cityOnScreen = computed(() => builtManifest.value !== null); + const lastError = signal(null); + const detail = signal(null); + const lastUpdatedAt = signal(0); + const buildProgress = signal(null); + + const overlay = signal({ + visible: false, + showOpts: null, + activeStep: null, + stepTails: {}, + }); + const pendingLabel = signal(null); + const cancel = signal<(() => void) | null>(null); + + // ── Status transitions (single owner of each state + its coupled writes) ── + + // Every rebuild path goes through these, so the status/error/timestamp set + // can't drift across call sites. markIdle ends every applyManifest. + function markRebuilding(): void { + rebuildStatus.value = RebuildStatus.Rebuilding; + detail.value = null; + buildProgress.value = null; + } + + // Decoration is the build's last stage, not the end of it: the overlay stays + // up through the tree pass, and the trees land at the END of that stage. + function markDecorating(): void { + rebuildStatus.value = RebuildStatus.Decorating; + enterBuildStage(BuildStage.Decorate); + } + + /** The city is on screen. Set by the composer once the meshes exist and a + * frame carrying them has been presented — NOT when applyStructure returns. */ + function markIdle(): void { + rebuildStatus.value = RebuildStatus.Idle; + builtManifest.value = manifest.current.peek(); + lastError.value = null; + detail.value = null; + buildProgress.value = null; + lastUpdatedAt.value = Date.now(); + } + + /** The canvas went away (leaving `/city` unmounts it), so nothing is on + * screen: the remount's rebuild is a load with a world to wait for. */ + function markGone(): void { + rebuildStatus.value = RebuildStatus.Pending; + builtManifest.value = null; + detail.value = null; + buildProgress.value = null; + } + + function markError(err: unknown): void { + rebuildStatus.value = RebuildStatus.Error; + // Logged with the stack, where a developer can use it. The UI shows a + // generic line: the message names our internals and a user cannot act on it. + console.error('[codecity] city build failed', err); + lastError.value = err instanceof Error ? err.message : String(err); + detail.value = null; + buildProgress.value = null; + } + + // ── Build stages ─────────────────────────────────────────────────── + + // The plan is per build: only what runs is an honest denominator. + function beginBuild(stages: readonly BuildStage[]): void { + buildProgress.value = { stages, index: 0, percent: null }; + } + + /** Advance to a stage of the declared plan. A stage the plan didn't list is + * ignored rather than appended: the denominator was already shown. */ + function enterBuildStage(stage: BuildStage): void { + const prev = buildProgress.peek(); + if (!prev) return; + const index = prev.stages.indexOf(stage); + if (index < 0 || index === prev.index) return; + buildProgress.value = { ...prev, index, percent: null }; + } + + function setBuildStagePercent(percent: number): void { + const prev = buildProgress.peek(); + if (!prev || prev.percent === percent) return; + buildProgress.value = { ...prev, percent }; + } + + const reporter: BuildReporter = { + beginBuild, + enterBuildStage, + setBuildStagePercent, + markRebuilding, + markDecorating, + markIdle, + markError, + markGone, + status: () => rebuildStatus.peek(), }; -} -// ── The Live driver ────────────────────────────────────────────────── + // ── What the overlay shows ───────────────────────────────────────── -export function attachOverlayDriver(): () => void { - const stops = [attachLoadReaction(), attachBuildReaction()]; - return () => stops.forEach((stop) => stop()); -} - -// The overlay's row only: these stages pass in a few frames, and the freshness -// readout flickering through them cost more attention than they are worth. -function attachBuildReaction(): () => void { - return effect(() => { - setLoadingStepTail(LoadingStep.Building, buildStageTail(BUILD_PROGRESS.value)); - }); -} + // peek, not value: these are called from inside effects, and tracking the + // prior state would subscribe an effect to a signal it goes on to write. -// The overlay's whole lifetime: up while a world is coming, down once that -// world is ON SCREEN. The stream ending is only the middle of that wait. -function attachLoadReaction(): () => void { - let overlayUp = false; - // How far down the list this load has got. A row that lights up again after a - // later one reads as the whole load starting over. - let reached = -1; - const advance = (step: LoadingStep): void => { - if (!overlayUp) return; - const index = LOADING_STEPS.indexOf(step); - if (index <= reached) return; - reached = index; - setLoadingStep(step); - }; - // Nothing was fetched, so the build row is the only honest one. An overlay - // already up belongs to whoever raised it (Timeline brings its own list). - const showForBuild = (): void => { - if (LOADING_OVERLAY.peek().visible) return; - PENDING_SOURCE_LABEL.value = SOURCE_INFO.peek().label || null; - showLoadingOverlay({ kind: null, steps: BUILD_ONLY_STEPS }); - overlayUp = true; - reached = -1; - }; - return effect(() => { - const p = SCAN_PROGRESS.value; - // The stream finishing is not the city appearing: Idle means a frame of it - // has been presented, so every earlier status keeps the overlay up. - const status = REBUILD_STATUS.value; - const building = status === RebuildStatus.Rebuilding || status === RebuildStatus.Decorating; - const onScreen = CITY_ON_SCREEN.value; - const hide = () => { - if (overlayUp) hideLoadingOverlay(); - overlayUp = false; + // Omitting onCancel leaves any registered handler in place, so a caller can + // pre-register one before the reaction shows the overlay. + function showOverlay(opts: LoadingOverlayShowOpts, onCancel?: (() => void) | null): void { + overlay.value = { + visible: true, + showOpts: opts, + activeStep: firstStepFor(opts.steps ?? LOADING_STEPS, opts.kind), + stepTails: {}, + }; + if (onCancel !== undefined) cancel.value = onCancel; + } + + function hideOverlay(): void { + overlay.value = { ...overlay.peek(), visible: false }; + cancel.value = null; + // The header belongs to the overlay, so it clears here rather than at each + // call site: one that forgets leaves a stale label over the next load. + pendingLabel.value = null; + } + + function setStep(step: LoadingStep): void { + const prev = overlay.peek(); + if (prev.activeStep === step) return; + overlay.value = { ...prev, activeStep: step }; + } + + function setStepTail(step: LoadingStep, tail: string | null): void { + const prev = overlay.peek(); + overlay.value = { ...prev, stepTails: { ...prev.stepTails, [step]: tail } }; + } + + // ── The driver ───────────────────────────────────────────────────── + + // The overlay's row only: these stages pass in a few frames, and the + // freshness readout flickering through them cost more than they are worth. + function attachBuildReaction(): () => void { + return effect(() => { + setStepTail(LoadingStep.Building, buildStageTail(buildProgress.value)); + }); + } + + // The overlay's whole lifetime: up while a world is coming, down once that + // world is ON SCREEN. The stream ending is only the middle of that wait. + function attachLoadReaction(): () => void { + let overlayUp = false; + // How far down the list this load has got. A row that lights up again after + // a later one reads as the whole load starting over. + let reached = -1; + const advance = (step: LoadingStep): void => { + if (!overlayUp) return; + const index = LOADING_STEPS.indexOf(step); + if (index <= reached) return; + reached = index; + setStep(step); }; - if (!p) { - // A build is all that's left to wait for: this overlay's own, or one for - // a city not up yet. A rebuild UNDER one is the footer's to report. - if (building && (overlayUp || !onScreen)) { - if (!overlayUp) showForBuild(); - advance(LoadingStep.Building); + // Nothing was fetched, so the build row is the only honest one. An overlay + // already up belongs to whoever raised it (Timeline brings its own list). + const showForBuild = (): void => { + if (overlay.peek().visible) return; + pendingLabel.value = source.info.peek().label || null; + showOverlay({ kind: null, steps: BUILD_ONLY_STEPS }); + overlayUp = true; + reached = -1; + }; + return effect(() => { + const p = scan.value; + // The stream finishing is not the city appearing: Idle means a frame of + // it has been presented, so every earlier status keeps the overlay up. + const status = rebuildStatus.value; + const building = status === RebuildStatus.Rebuilding || status === RebuildStatus.Decorating; + const onScreen = cityOnScreen.value; + const hide = () => { + if (overlayUp) hideOverlay(); + overlayUp = false; + }; + if (!p) { + // A build is all that's left to wait for: this overlay's own, or one for + // a city not up yet. A rebuild UNDER one is the footer's to report. + if (building && (overlayUp || !onScreen)) { + if (!overlayUp) showForBuild(); + advance(LoadingStep.Building); + return; + } + hide(); return; } - hide(); - return; - } - // A load's first event: a new list, so it starts from the top again. - if (p.phase === null) reached = -1; - if (!overlayUp) { - // null→non-null: show the overlay at the kind-based initial step - // (Resolving for git, Scanning for local). - showLoadingOverlay({ kind: p.kind, branch: p.branch }); - overlayUp = true; - reached = LOADING_STEPS.indexOf(firstStepFor(LOADING_STEPS, p.kind)); - } - advance(stepForPhase(p.phase, p.kind, p.appliedPending)); - if (p.phase === ScanPhase.CloneProgress) { - // A heartbeat during the silent promisor blob fetch has no percent at - // all, and shows the working tree growing on disk instead. - const tail = transferTail(p) ?? (p.mbOnDisk !== undefined ? `${p.mbOnDisk} MB` : null); - setLoadingStepTail(LoadingStep.Cloning, tail); - } else if (p.phase === ScanPhase.ScanProgress) { - setLoadingStepTail( - LoadingStep.Scanning, - p.filesScanned !== undefined ? `${p.filesScanned.toLocaleString()} files` : null - ); - } else if (p.phase === ScanPhase.PartialManifest || p.phase === ScanPhase.CompleteManifest) { - // Progress tails done. - setLoadingStepTail(LoadingStep.Cloning, null); - setLoadingStepTail(LoadingStep.Scanning, null); - } - // p.phase === null: just-started; showLoadingOverlay already set the - // kind-based initial step — nothing more to do until a real event. - }); + // A load's first event: a new list, so it starts from the top again. + if (p.phase === null) reached = -1; + if (!overlayUp) { + // null→non-null: show the overlay at the kind-based initial step + // (Resolving for git, Scanning for local). + showOverlay({ kind: p.kind, branch: p.branch }); + overlayUp = true; + reached = LOADING_STEPS.indexOf(firstStepFor(LOADING_STEPS, p.kind)); + } + advance(stepForPhase(p.phase, p.kind, p.appliedPending)); + if (p.phase === ScanPhase.CloneProgress) { + // A heartbeat during the silent promisor blob fetch has no percent at + // all, and shows the working tree growing on disk instead. + const tail = transferTail(p) ?? (p.mbOnDisk !== undefined ? `${p.mbOnDisk} MB` : null); + setStepTail(LoadingStep.Cloning, tail); + } else if (p.phase === ScanPhase.ScanProgress) { + setStepTail( + LoadingStep.Scanning, + p.filesScanned !== undefined ? `${p.filesScanned.toLocaleString()} files` : null + ); + } else if (p.phase === ScanPhase.PartialManifest || p.phase === ScanPhase.CompleteManifest) { + // Progress tails done. + setStepTail(LoadingStep.Cloning, null); + setStepTail(LoadingStep.Scanning, null); + } + // p.phase === null: just-started; showOverlay already set the kind-based + // initial step — nothing more to do until a real event. + }); + } + + return { + scan, + rebuildStatus, + builtManifest, + cityOnScreen, + lastError, + detail, + lastUpdatedAt, + buildProgress, + reporter, + setDetail: (next) => { + detail.value = next; + }, + overlay, + pendingLabel, + cancel, + showOverlay, + hideOverlay, + setStep, + setStepTail, + setCancel: (onCancel) => { + cancel.value = onCancel; + }, + attachOverlayDriver() { + const stops = [attachLoadReaction(), attachBuildReaction()]; + return () => stops.forEach((stop) => stop()); + }, + }; } diff --git a/app/src/state/stores/source.ts b/app/src/state/stores/source.ts index 0383bb49..fbe04105 100644 --- a/app/src/state/stores/source.ts +++ b/app/src/state/stores/source.ts @@ -1,17 +1,13 @@ -// state/stores/source.ts — which repo: the one you opened, the ones before it, -// the folders you hid inside it, and the one merely showing behind the landing. -// The excludes are here rather than in settings/ because useManifestSource -// sends them in the manifest URL beside src and branch. +// state/stores/source.ts — which repo. Per project: the one this session +// opened, how it failed, and the folders hidden inside it. App-wide: the ones +// you have opened before, the exclude lists they are keyed into, and the repo +// merely showing behind the landing. The excludes are here rather than in +// settings/ because the fetch layer sends them in the manifest URL. -import { signal, computed, effect, type ReadonlySignal } from '@preact/signals'; +import { signal, computed, type ReadonlySignal, type Signal } from '@preact/signals'; import { persistedSignal } from '@/state/persist'; import { PERSISTED_KEYS } from '@/constants/storage'; import { MAX_RECENT_SOURCES } from '@/constants/ui'; -import { URL_PARAMS } from '@/constants/urlParams'; -import { VIEW_PARAMS } from '@/router/params'; -import { ROUTES } from '@/router/paths'; -import { navigate, hrefFor, ROUTE_SEARCH, ROUTE_PATH } from '@/router/location'; -import { MANIFEST, setManifest } from '@/state/stores/manifest'; import { srcKind, SourceKind, @@ -20,72 +16,16 @@ import { sourceKey, sameSourceIdentity, } from '@/utils/sources'; +import type { ManifestStore } from './manifest'; import type { Manifest, SourceError } from '@/types'; -// ── Currently-loaded source ────────────────────────────────────────── +// ── One project's source ───────────────────────────────────────────── -/** The applied source, or null when none is (cold boot / picker open). Written - * only by commitSource below, so it means "a load succeeded". */ -export const CURRENT_SOURCE = signal<{ src: string; branch?: string } | null>(null); - -/** The last load failure, or null. A fetch outcome, not a UI command: App - * reacts by opening the picker, and clears it when the user acts. */ -export const SOURCE_ERROR = signal(null); - -/** What is on screen: the project you opened, or the featured repo the landing - * renders. Lists mark rows against this, so one repo marks the same way. */ -export const ACTIVE_SOURCE = computed<{ src: string; branch?: string } | null>(() => { - const current = CURRENT_SOURCE.value; - if (current) return current; - const backdrop = BACKDROP_CITY.value; - return backdrop ? { src: backdrop.src, branch: backdrop.branch } : null; -}); - -/** Whether these name the project already committed: the one question three - * call sites were each asking of CURRENT_SOURCE by hand. */ -export function isOpenedSource(src?: string | null, branch?: string | null): boolean { - const cur = CURRENT_SOURCE.peek(); - return !!cur && !!src && sameSourceIdentity(cur, { src, branch: branch ?? undefined }); -} - -/** The loaded source's stable hash, or null. Namespaces per-source storage. */ -export const CURRENT_SOURCE_KEY = computed(() => - CURRENT_SOURCE.value ? sourceKey(CURRENT_SOURCE.value.src, CURRENT_SOURCE.value.branch) : null -); - -/** Drop the load from the URL and go home: a cancel with nothing to fall back - * to must not leave a reload re-running what it called off. */ -export function clearSourceUrl(): void { - // Anything the app does not own (an ?utm_source, say) rides along home: only - // the params describing the load that was called off are dropped. - const params = new URLSearchParams(ROUTE_SEARCH.peek()); - for (const key of [...Object.values(URL_PARAMS), ...Object.values(VIEW_PARAMS)]) { - params.delete(key); - } - navigate(hrefFor(ROUTES.HOME, params), { replace: true }); +export interface SourceRef { + src: string; + branch?: string; } -// Reflect the applied source so reload/share reopens it, moving to /city if the -// load began at home. -effect(() => { - const cur = CURRENT_SOURCE.value; - if (!cur) return; - const params = new URLSearchParams(ROUTE_SEARCH.peek()); - // A different project than the URL was describing: its mode, scrub commit - // and selection belong to the one that just left. - const had = params.get(URL_PARAMS.SRC); - if (had && !isOpenedSource(had, params.get(URL_PARAMS.BRANCH) ?? undefined)) { - for (const key of Object.values(VIEW_PARAMS)) params.delete(key); - } - params.set(URL_PARAMS.SRC, cur.src); - if (cur.branch) params.set(URL_PARAMS.BRANCH, cur.branch); - else params.delete(URL_PARAMS.BRANCH); - // From the switcher this is a place you went, so it pushes and Back returns - // to the list; already on /city (a re-scan, a deep link) is the same place. - const fromHome = ROUTE_PATH.peek() === ROUTES.HOME; - navigate(hrefFor(ROUTES.CITY, params), { replace: !fromHome }); -}); - export interface SourceInfo { /** Human-readable project label (owner/repo or directory name). */ label: string; @@ -97,29 +37,113 @@ export interface SourceInfo { src: string | undefined; } -/** A working tree on disk rather than a clone. Only a working tree can change - * under the app, so anything watching for change keys off this. */ -export const CURRENT_SOURCE_IS_LOCAL = computed(() => { - const cur = CURRENT_SOURCE.value; - return cur ? srcKind(cur.src) === SourceKind.Local : false; -}); +export interface SourceStore { + /** The applied source, or null when none is. Written only by commit(), so it + * means "a load succeeded". */ + readonly current: Signal; + /** The last load failure, or null. A fetch outcome, not a UI command. */ + readonly error: Signal; + /** Its stable hash, or null. Namespaces per-source storage. */ + readonly key: ReadonlySignal; + /** A working tree on disk rather than a clone: only a working tree changes + * under the app, so anything watching for change keys off this. */ + readonly isLocal: ReadonlySignal; + /** Everything the chrome prints about it, off the manifest it loaded. */ + readonly info: ReadonlySignal; + /** The folders hidden in this repo (empty when none / no source). */ + readonly excludes: ReadonlySignal; + /** Commit a loaded source: this session's source, its recents entry, and the + * manifest the UI reads. Every mode ends its load here. */ + commit(src: string, branch: string | undefined, manifest: Manifest): void; + /** Whether these name the project this session already has open. */ + isOpen(src?: string | null, branch?: string | null): boolean; + /** Hide `path` from this repo's city, restore one, restore all. */ + addExclude(path: string): void; + removeExclude(path: string): void; + clearExcludes(): void; +} + +export function createSourceStore({ manifest }: { manifest: ManifestStore }): SourceStore { + const current = signal(null); + const error = signal(null); + + const key = computed(() => + current.value ? sourceKey(current.value.src, current.value.branch) : null + ); + + const isLocal = computed(() => { + const cur = current.value; + return cur ? srcKind(cur.src) === SourceKind.Local : false; + }); + + const info = computed(() => { + const cur = current.value; + const m = manifest.current.value; + if (!cur || !m) { + return { label: '', branch: undefined, sourceUrl: undefined, src: undefined }; + } + const loaded = m as Manifest; + return { + label: loaded.tree?.name ?? '', + branch: resolveBranch(loaded, cur.branch), + sourceUrl: srcKind(cur.src) === SourceKind.Remote ? cur.src : undefined, + src: cur.src, + }; + }); + + const repoKey = computed(() => { + const cur = current.value; + return cur ? repoKeyFor(cur.src) : null; + }); + + const excludes = computed(() => { + const repo = repoKey.value; + return repo ? (EXCLUDES.value[repo] ?? []) : []; + }); -export const SOURCE_INFO = computed(() => { - const cur = CURRENT_SOURCE.value; - const m = MANIFEST.value; - if (!cur || !m) { - return { label: '', branch: undefined, sourceUrl: undefined, src: undefined }; + function setExcludes(next: string[]): void { + const cur = current.peek(); + if (!cur) return; // no source loaded: nothing to key against + setExcludesFor(cur.src, next); } - const manifest = m as Manifest; + return { - label: manifest.tree?.name ?? '', - branch: resolveBranch(manifest, cur.branch), - sourceUrl: srcKind(cur.src) === SourceKind.Remote ? cur.src : undefined, - src: cur.src, + current, + error, + key, + isLocal, + info, + excludes, + commit(src, branch, loaded) { + // ONE identity for the load, used by both writes below. Deriving it twice + // is what listed a repo twice (#185): see the test for the two rows. + const checkout = resolveBranch(loaded, branch); + // A local source carries no branch: its checkout is dynamic, so identity + // omits it. The header still shows it, read off the manifest. + const idBranch = identityBranch(src, checkout); + // Source before manifest: the camera-reframe reaction reads the source at + // apply-start, and the apply is kicked off by the manifest write. + current.value = { src, branch: idBranch }; + manifest.set(loaded); + pushRecent({ + src, + // tree.name is the canonical owner/repo; a worktree's basename is its folder. + label: loaded.tree?.name || src, + branch: idBranch, + checkout, + }); + }, + isOpen: (src, branch) => { + const cur = current.peek(); + return !!cur && !!src && sameSourceIdentity(cur, { src, branch: branch ?? undefined }); + }, + addExclude: (path) => setExcludes([...excludes.peek(), path]), + removeExclude: (path) => setExcludes(excludes.peek().filter((p) => p !== path)), + clearExcludes: () => setExcludes([]), }; -}); +} -// ── Recently-opened sources (persisted) ────────────────────────────── +// ── Recently-opened sources (persisted, app-wide) ──────────────────── export interface RecentSource { src: string; // exactly what was typed / passed; goes into ?src= @@ -143,28 +167,6 @@ export function pushRecent(entry: Omit): void { RECENTS.value = filtered.slice(0, MAX_RECENT_SOURCES); } -/** Commit a loaded source: CURRENT_SOURCE, its recents entry, and the manifest - * the UI reads. Every mode ends its load here, with its own manifest. */ -export function commitSource(src: string, branch: string | undefined, manifest: Manifest): void { - // ONE identity for the load, used by both writes below. Deriving it twice is - // what listed a repo twice (#185): see the test for the two rows it produced. - const checkout = resolveBranch(manifest, branch); - // A local source carries no branch: its checkout is dynamic, so identity omits - // it. The header still shows it, read off the manifest — display, not identity. - const idBranch = identityBranch(src, checkout); - // Source before manifest: the camera-reframe reaction reads CURRENT_SOURCE at - // apply-start, and the apply is kicked off by the manifest write. - CURRENT_SOURCE.value = { src, branch: idBranch }; - setManifest(manifest); - pushRecent({ - src, - // tree.name is the canonical owner/repo; a worktree's basename is its folder. - label: manifest.tree?.name || src, - branch: idBranch, - checkout, - }); -} - /** Drop the entry matching the given source identity. No-op if not present. */ export function removeRecent(src: string, branch?: string): void { RECENTS.value = RECENTS.value.filter((r) => !sameSourceIdentity(r, { src, branch })); @@ -183,8 +185,8 @@ export enum BackdropKind { export interface BackdropCity { src: string; label: string; - /** The loaded branch, normalised like CURRENT_SOURCE's: identity includes it, - * so a row storing @main only matches when this carries it too. */ + /** The loaded branch, normalised like a session's: identity includes it, so a + * row storing @main only matches when this carries it too. */ branch?: string; kind: BackdropKind; } @@ -193,7 +195,16 @@ export interface BackdropCity { * you can't see. Null means the hero image is what's showing. */ export const BACKDROP_CITY = signal(null); -// ── Folders you have hidden, per repo ───────────────────────────────── +/** What is on screen: the project a session opened, or the repo the landing is + * rendering behind itself. Lists mark rows against it, so one repo marks the + * same way wherever you are. Read during render — it tracks the backdrop. */ +export function activeSourceOf(opened: SourceRef | null): SourceRef | null { + if (opened) return opened; + const backdrop = BACKDROP_CITY.value; + return backdrop ? { src: backdrop.src, branch: backdrop.branch } : null; +} + +// ── Folders you have hidden, per repo (app-wide, keyed by repo) ─────── /** repo key -> sorted, de-duped rel-paths. Whole-object persistence: the keys * are runtime repo hashes, so diff-vs-default would drop every write. */ @@ -208,17 +219,6 @@ function repoKeyFor(src: string): string { return sourceKey(src); } -function currentRepoKey(): string | null { - const cur = CURRENT_SOURCE.value; - return cur ? repoKeyFor(cur.src) : null; -} - -/** The loaded repo's exclude list (empty when no source / none set). Reactive. */ -export const ACTIVE_EXCLUDES: ReadonlySignal = computed(() => { - const key = currentRepoKey(); - return key ? (EXCLUDES.value[key] ?? []) : []; -}); - /** Peek the excludes for an explicit src — for the imperative fetch layer. */ export function activeExcludePathsFor(src: string): string[] { return EXCLUDES.peek()[repoKeyFor(src)] ?? []; @@ -227,31 +227,10 @@ export function activeExcludePathsFor(src: string): string[] { /** Replace one repo's exclude list. Sorted and de-duped, and an empty list * drops the slot so the store holds only repos that hide something. */ export function setExcludesFor(src: string, next: readonly string[]): void { - const key = repoKeyFor(src); + const repo = repoKeyFor(src); const sorted = [...new Set(next)].sort(); const map = { ...EXCLUDES.peek() }; - if (sorted.length === 0) delete map[key]; - else map[key] = sorted; + if (sorted.length === 0) delete map[repo]; + else map[repo] = sorted; EXCLUDES.value = map; } - -function setForCurrentRepo(next: string[]): void { - const cur = CURRENT_SOURCE.peek(); - if (!cur) return; // no source loaded: nothing to key against - setExcludesFor(cur.src, next); -} - -/** Hide `path` from the current repo's city. Sorted + de-duped. No-op if none. */ -export function addExclude(path: string): void { - setForCurrentRepo([...(EXCLUDES.peek()[currentRepoKey() ?? ''] ?? []), path]); -} - -/** Restore `path` (remove from the current repo's excludes). */ -export function removeExclude(path: string): void { - setForCurrentRepo((EXCLUDES.peek()[currentRepoKey() ?? ''] ?? []).filter((p) => p !== path)); -} - -/** Restore everything for the current repo. */ -export function clearExcludes(): void { - setForCurrentRepo([]); -} diff --git a/app/src/state/stores/timeline.ts b/app/src/state/stores/timeline.ts index efd1d41e..e262c587 100644 --- a/app/src/state/stores/timeline.ts +++ b/app/src/state/stores/timeline.ts @@ -1,14 +1,14 @@ -// state/stores/timeline.ts — the history you are looking at and where you are in -// it: the mode, the loaded bundle, the scrub position, and everything that +// state/stores/timeline.ts — the history ONE project is being looked at +// through: the mode, the loaded bundle, the scrub position, and everything that // position implies. Derived reads live beside the position they read because // resetting the mode moves it, so splitting them would make the two files cyclic. -import { signal, batch, computed, effect, type ReadonlySignal } from '@preact/signals'; +import { signal, batch, computed, effect, type ReadonlySignal, type Signal } from '@preact/signals'; import type { DirNode, Manifest, TimelineBundle, TreeNode } from '@/types'; import { NodeKind } from '@/types'; import { parseDateMs, epochDayAt } from '@/utils/dates'; import { findNodeByPath } from '@/utils/manifest'; -import { MANIFEST, type ManifestValue } from './manifest'; +import type { ManifestStore, ManifestValue } from './manifest'; import { buildPathTimelines, ruinStateAt, @@ -20,115 +20,8 @@ import { } from '@/city/timeline/replay'; import type { PathTimeline } from '@/city/timeline/replay'; -// ── Mode, history, and where you are in it ─────────────────────────── - -// Distinct render mode (union city + scrub). SCRUB_POS is a float commit index so scrubbing interpolates. -export const TIMELINE_MODE = signal(false); -export const TIMELINE_BUNDLE = signal(null); - -/** How many commits the loaded bundle holds. */ -const COMMIT_COUNT = computed(() => TIMELINE_BUNDLE.value?.commits.length ?? 0); - -/** Read once per Timeline entry rather than per frame: the stop today sits at - * has to hold still while it is being scrubbed to. */ -const _todayMs = signal(Date.now()); - -/** Today, when it is later than the last commit: the city goes on aging after - * the last thing anyone committed. Null when the newest commit is today. */ -export const SCRUB_TODAY_MS = computed(() => { - const bundle = TIMELINE_BUNDLE.value; - if (!bundle || bundle.commits.length === 0) return null; - // Parsed the way the bar prints dates, so the stop lands on the day it names. - const newest = parseDateMs(bundle.commits[bundle.commits.length - 1].date); - const today = _todayMs.value; - if (!Number.isFinite(newest)) return null; - // Whole days: a commit from this morning is not a stop away from now, and a - // track ending hours past its last tick reads as a rounding error. - return Math.floor(epochDayAt(today)) > Math.floor(epochDayAt(newest)) ? today : null; -}); - -/** The moment Timeline treats as now. Set on entry; tests pin it. */ -export function setTodayMs(ms: number): void { - _todayMs.value = ms; -} - -/** Highest valid scrub index for the loaded bundle, 0 when there is none. One - * past the last commit when today is a stop of its own. */ -export const SCRUB_MAX = computed( - () => Math.max(0, COMMIT_COUNT.value - 1) + (SCRUB_TODAY_MS.value === null ? 0 : 1) -); - -const _scrubPos = signal(0); - -// Clamped against the current bundle, not at each write, so a bundle swap alone -// can't leave a stale position out of range. Readers never clamp defensively. -export const SCRUB_POS: ReadonlySignal = computed(() => - Math.min(Math.max(_scrubPos.value, 0), SCRUB_MAX.value) -); - -/** The only way to move the scrubber; readonly SCRUB_POS makes that a type error to bypass. */ -export function setScrubPos(pos: number): void { - _scrubPos.value = pos; -} - -// The whole commit SCRUB_POS lands on, so anything keyed on presence recomputes -// once a crossing. Capped: past the last one the city is still its, only older. -export const SCRUB_COMMIT = computed(() => - Math.min(Math.floor(SCRUB_POS.value), Math.max(0, COMMIT_COUNT.value - 1)) -); - -// Anything that would reflow the layout waits for this to clear, or the track -// resizes under the pointer mid-drag and the position jumps. -export const SCRUB_DRAGGING = signal(false); - -// The commit content fetches key on: follows the scrub but holds still mid-drag, -// so dragging across a long history doesn't refetch once per commit crossed. -export const SETTLED_COMMIT = signal(0); - -// The same rest point as a position. SETTLED_COMMIT caps at the newest commit, -// so it can't tell that stop from the today stop past it; this can. -export const SETTLED_POS = signal(0); - -effect(() => { - const commit = SCRUB_COMMIT.value; - const pos = SCRUB_POS.value; - if (SCRUB_DRAGGING.value) return; - batch(() => { - SETTLED_COMMIT.value = commit; - SETTLED_POS.value = pos; - }); -}); - -// Called BEFORE the union city is packed: the mode tells the scene layer whose -// city to pack. The position follows, once its bundle is loaded. -export function beginTimelineMode(): void { - batch(() => { - TIMELINE_MODE.value = true; - _todayMs.value = Date.now(); - }); -} - -// Shared by every exit path (toggle-off, source switch); scene-free, the scene layer reacts to TIMELINE_MODE. -export function resetTimelineMode(): void { - batch(() => { - TIMELINE_MODE.value = false; - setScrubPos(0); - TIMELINE_BUNDLE.value = null; - SCRUB_DRAGGING.value = false; - }); -} - -// ── What that position implies ─────────────────────────────────────── - const EMPTY: ReadonlySet = new Set(); -// Timelines depend only on the bundle — rebuild when it changes, NOT per scrub. -// (A second copy from the scrub controller's; both are pure reads of the bundle.) -const _TIMELINES = computed | null>(() => { - const bundle = TIMELINE_BUNDLE.value; - return bundle ? buildPathTimelines(bundle) : null; -}); - // Record present paths into `out`; returns whether this node is present. A file // is present when live at pos; a directory is present iff any descendant is. function _collect( @@ -160,49 +53,6 @@ export interface ScrubbedFileStats { atDeletion: boolean; } -// .value, not .peek(): the footer reads this in render. At the SETTLED commit, -// so the number always describes the blob the content fetch serves. -export function scrubbedStatsFor(path: string): ScrubbedFileStats | null { - if (!TIMELINE_MODE.value) return null; - const pt = _TIMELINES.value?.get(path); - if (!pt) return null; - const pos = SETTLED_COMMIT.value; - const gone = statsAtDeletion(pt, pos); - if (gone) return { lines: gone.lines, bytes: gone.bytes, atDeletion: true }; - const entry = entryAt(pt, pos); - if (!entry) return null; - return { lines: entry.lines, bytes: entry.bytes, atDeletion: false }; -} - -/** Blob sha for a path at the scrub position; null in Live or when absent. */ -export function scrubbedBlobShaFor(path: string | null | undefined): string | null { - if (!path || !TIMELINE_MODE.value) return null; - const pt = _TIMELINES.value?.get(path); - // Settled, not live: content fetches wait for the drag to end. - return pt ? blobShaAt(pt, SETTLED_COMMIT.value) : null; -} - -/** No content to fetch at this scrub position, so callers must NOT fall back to - * a by-path read: that hits HEAD, where a union file may not exist (#122). */ -export function hasNoContentAtScrub(path: string | null | undefined): boolean { - return TIMELINE_MODE.value && scrubbedBlobShaFor(path) === null; -} - -export const PRESENT_PATHS: ReadonlySignal> = computed(() => { - if (!TIMELINE_MODE.value) return EMPTY; - const bundle = TIMELINE_BUNDLE.value; - const timelines = _TIMELINES.value; - if (!bundle || !timelines) return EMPTY; - const pos = SCRUB_COMMIT.value; - const tree = (bundle.unionManifest as unknown as { tree?: TreeNode }).tree; - if (!tree) return EMPTY; - const out = new Set(); - _collect(tree, timelines, pos, out); - return out; -}); - -// ── The tree the panes render ──────────────────────────────────────── - // Keep a node iff it's present at the scrubbed commit. A present directory always // has ≥1 present descendant, so filtering its children never leaves it empty. function _filterPresent(node: TreeNode, present: ReadonlySet): TreeNode | null { @@ -216,25 +66,6 @@ function _filterPresent(node: TreeNode, present: ReadonlySet): TreeNode return { ...node, children }; } -export const PANE_MANIFEST: ReadonlySignal = computed(() => { - const bundle = TIMELINE_BUNDLE.value; - if (!TIMELINE_MODE.value || !bundle) return MANIFEST.value; - - const union = bundle.unionManifest as unknown as ManifestValue; - const tree = (union as { tree?: DirNode } | null)?.tree; - if (!tree) return union; - - // Root always stays (an empty tree still needs a container); its children are - // filtered to the present-at-scrub subtrees. - const present = PRESENT_PATHS.value; - const children: TreeNode[] = []; - for (const child of tree.children ?? []) { - const kept = _filterPresent(child, present); - if (kept) children.push(kept); - } - return { ...(union as object), tree: { ...tree, children } } as unknown as ManifestValue; -}); - // ── A folder's rollups at the scrub position ───────────────────────── // Mirrors api/scan/treebuild.py's accumulator: files add themselves, a finished @@ -366,20 +197,226 @@ function _rebuildAt( } as TreeNode; } -/** A directory at the settled commit: the union's structure, every measure - * re-added from the per-blob numbers the buildings use. Null in Live. */ -export function scrubbedDirFor(path: string): DirNode | null { - if (!TIMELINE_MODE.value) return null; - const bundle = TIMELINE_BUNDLE.value; - const timelines = _TIMELINES.value; - if (!bundle || !timelines) return null; - const union = (bundle.unionManifest as unknown as { tree?: TreeNode }).tree; - if (!union) return null; - - const node = findNodeByPath(bundle.unionManifest as unknown as Manifest, path); - if (!node || node.type !== NodeKind.Directory) return null; - - const commitDates = bundle.commits.map((c) => (c as { date?: string }).date); - const rebuilt = _rebuildAt(node, SETTLED_COMMIT.value, timelines, commitDates, _emptyRollup()); - return (rebuilt as DirNode | null) ?? null; +export interface TimelineStore { + /** Distinct render mode (union city + scrub). */ + readonly mode: Signal; + readonly bundle: Signal; + /** Float commit index, clamped against the loaded bundle, so scrubbing + * interpolates and a bundle swap cannot strand it out of range. */ + readonly scrubPos: ReadonlySignal; + /** Highest valid index, 0 when there is no bundle. */ + readonly scrubMax: ReadonlySignal; + /** Today, when it is later than the last commit; null when it is not. */ + readonly todayMs: ReadonlySignal; + /** The whole commit scrubPos lands on. */ + readonly scrubCommit: ReadonlySignal; + /** Anything that would reflow the layout waits for this to clear, or the + * track resizes under the pointer mid-drag and the position jumps. */ + readonly dragging: Signal; + /** The commit content fetches key on: follows the scrub but holds still + * mid-drag, so dragging a long history doesn't refetch per commit crossed. */ + readonly settledCommit: Signal; + /** The same rest point as a position: settledCommit caps at the newest + * commit, so it cannot tell that stop from the today stop past it. */ + readonly settledPos: Signal; + /** The tree the panes render: the union filtered to what exists at the + * scrub, or this project's live manifest outside the mode. */ + readonly paneManifest: ReadonlySignal; + readonly presentPaths: ReadonlySignal>; + /** The only way to move the scrubber; a readonly scrubPos makes bypassing it + * a type error. */ + setScrubPos(pos: number): void; + /** The moment Timeline treats as now. Set on entry; tests pin it. */ + setTodayMs(ms: number): void; + /** Called BEFORE the union city is packed: the mode tells the scene layer + * whose city to pack. The position follows, once its bundle is loaded. */ + begin(): void; + /** Shared by every exit path (toggle-off, source switch); scene-free. */ + reset(): void; + /** A file's measures at the scrub position, or at its deletion if gone. */ + scrubbedStatsFor(path: string): ScrubbedFileStats | null; + /** Blob sha for a path at the scrub position; null in Live or when absent. */ + scrubbedBlobShaFor(path: string | null | undefined): string | null; + /** No content to fetch here, so callers must NOT fall back to a by-path read: + * that hits HEAD, where a union file may not exist (#122). */ + hasNoContentAtScrub(path: string | null | undefined): boolean; + /** A directory at the settled commit: the union's structure, every measure + * re-added from the per-blob numbers the buildings use. Null in Live. */ + scrubbedDirFor(path: string): DirNode | null; + dispose(): void; +} + +export function createTimelineStore({ manifest }: { manifest: ManifestStore }): TimelineStore { + const mode = signal(false); + const bundle = signal(null); + + /** How many commits the loaded bundle holds. */ + const commitCount = computed(() => bundle.value?.commits.length ?? 0); + + /** Read once per Timeline entry rather than per frame: the stop today sits at + * has to hold still while it is being scrubbed to. */ + const _todayMs = signal(Date.now()); + + const todayMs = computed(() => { + const loaded = bundle.value; + if (!loaded || loaded.commits.length === 0) return null; + // Parsed the way the bar prints dates, so the stop lands on the day it names. + const newest = parseDateMs(loaded.commits[loaded.commits.length - 1].date); + const today = _todayMs.value; + if (!Number.isFinite(newest)) return null; + // Whole days: a commit from this morning is not a stop away from now, and a + // track ending hours past its last tick reads as a rounding error. + return Math.floor(epochDayAt(today)) > Math.floor(epochDayAt(newest)) ? today : null; + }); + + /** One past the last commit when today is a stop of its own. */ + const scrubMax = computed( + () => Math.max(0, commitCount.value - 1) + (todayMs.value === null ? 0 : 1) + ); + + const _scrubPos = signal(0); + + // Clamped against the current bundle, not at each write, so a bundle swap + // alone can't leave a stale position out of range. Readers never clamp. + const scrubPos: ReadonlySignal = computed(() => + Math.min(Math.max(_scrubPos.value, 0), scrubMax.value) + ); + + // The whole commit scrubPos lands on, so anything keyed on presence + // recomputes once a crossing. Capped: past the last one the city is still + // its, only older. + const scrubCommit = computed(() => + Math.min(Math.floor(scrubPos.value), Math.max(0, commitCount.value - 1)) + ); + + const dragging = signal(false); + const settledCommit = signal(0); + const settledPos = signal(0); + + const stopSettle = effect(() => { + const commit = scrubCommit.value; + const pos = scrubPos.value; + if (dragging.value) return; + batch(() => { + settledCommit.value = commit; + settledPos.value = pos; + }); + }); + + // Timelines depend only on the bundle — rebuilt when it changes, NOT per + // scrub. (A second copy from the scrub controller's; both are pure reads.) + const _timelines = computed | null>(() => { + const loaded = bundle.value; + return loaded ? buildPathTimelines(loaded) : null; + }); + + const presentPaths: ReadonlySignal> = computed(() => { + if (!mode.value) return EMPTY; + const loaded = bundle.value; + const timelines = _timelines.value; + if (!loaded || !timelines) return EMPTY; + const pos = scrubCommit.value; + const tree = (loaded.unionManifest as unknown as { tree?: TreeNode }).tree; + if (!tree) return EMPTY; + const out = new Set(); + _collect(tree, timelines, pos, out); + return out; + }); + + const paneManifest: ReadonlySignal = computed(() => { + const loaded = bundle.value; + if (!mode.value || !loaded) return manifest.current.value; + + const union = loaded.unionManifest as unknown as ManifestValue; + const tree = (union as { tree?: DirNode } | null)?.tree; + if (!tree) return union; + + // Root always stays (an empty tree still needs a container); its children + // are filtered to the present-at-scrub subtrees. + const present = presentPaths.value; + const children: TreeNode[] = []; + for (const child of tree.children ?? []) { + const kept = _filterPresent(child, present); + if (kept) children.push(kept); + } + return { ...(union as object), tree: { ...tree, children } } as unknown as ManifestValue; + }); + + function setScrubPos(pos: number): void { + _scrubPos.value = pos; + } + + // .value, not .peek(): the footer reads these in render. At the SETTLED + // commit, so the number always describes the blob the content fetch serves. + function scrubbedStatsFor(path: string): ScrubbedFileStats | null { + if (!mode.value) return null; + const pt = _timelines.value?.get(path); + if (!pt) return null; + const pos = settledCommit.value; + const gone = statsAtDeletion(pt, pos); + if (gone) return { lines: gone.lines, bytes: gone.bytes, atDeletion: true }; + const entry = entryAt(pt, pos); + if (!entry) return null; + return { lines: entry.lines, bytes: entry.bytes, atDeletion: false }; + } + + function scrubbedBlobShaFor(path: string | null | undefined): string | null { + if (!path || !mode.value) return null; + const pt = _timelines.value?.get(path); + // Settled, not live: content fetches wait for the drag to end. + return pt ? blobShaAt(pt, settledCommit.value) : null; + } + + function scrubbedDirFor(path: string): DirNode | null { + if (!mode.value) return null; + const loaded = bundle.value; + const timelines = _timelines.value; + if (!loaded || !timelines) return null; + const union = (loaded.unionManifest as unknown as { tree?: TreeNode }).tree; + if (!union) return null; + + const node = findNodeByPath(loaded.unionManifest as unknown as Manifest, path); + if (!node || node.type !== NodeKind.Directory) return null; + + const commitDates = loaded.commits.map((c) => (c as { date?: string }).date); + const rebuilt = _rebuildAt(node, settledCommit.value, timelines, commitDates, _emptyRollup()); + return (rebuilt as DirNode | null) ?? null; + } + + return { + mode, + bundle, + scrubPos, + scrubMax, + todayMs, + scrubCommit, + dragging, + settledCommit, + settledPos, + paneManifest, + presentPaths, + setScrubPos, + setTodayMs: (ms) => { + _todayMs.value = ms; + }, + begin() { + batch(() => { + mode.value = true; + _todayMs.value = Date.now(); + }); + }, + reset() { + batch(() => { + mode.value = false; + setScrubPos(0); + bundle.value = null; + dragging.value = false; + }); + }, + scrubbedStatsFor, + scrubbedBlobShaFor, + hasNoContentAtScrub: (path) => mode.value && scrubbedBlobShaFor(path) === null, + scrubbedDirFor, + dispose: stopSettle, + }; } diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts index d5782084..411567a5 100644 --- a/app/src/types/manifest.generated.ts +++ b/app/src/types/manifest.generated.ts @@ -4,1221 +4,1230 @@ */ export interface paths { - "/api/health": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Health */ - get: operations["health_api_health_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/config": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Config */ - get: operations["config_api_config_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/discover": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Discover */ - get: operations["discover_api_discover_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/file": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get File */ - get: operations["get_file_api_file_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/fingerprint": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Fingerprint - * @description A binary file's byte-pattern fingerprint as a PNG. Resolved exactly - * like GET /api/file. Raw binary bytes never leave the server: only the head is - * read, and only the image computed from it is returned. - */ - get: operations["get_fingerprint_api_fingerprint_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/commit": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Commit */ - get: operations["get_commit_api_commit_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/branches": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Branches */ - get: operations["get_branches_api_branches_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/manifest/signature": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Signature */ - get: operations["signature_api_manifest_signature_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/manifest/cached": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Cached Manifest - * @description The newest manifest already on disk for this source, or 404. Never - * scans, never clones, never resolves a ref over the network. - * - * Backs the landing backdrop, which wants a city to show rather than a - * current one. Everything else wants the truth and goes to /api/manifest. - */ - get: operations["cached_manifest_api_manifest_cached_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/manifest": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Manifest */ - get: operations["manifest_api_manifest_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/timeline": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Timeline */ - get: operations["timeline_api_timeline_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; + '/api/health': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations['health_api_health_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/config': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Config */ + get: operations['config_api_config_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/discover': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Discover */ + get: operations['discover_api_discover_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/file': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get File */ + get: operations['get_file_api_file_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/fingerprint': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Fingerprint + * @description A binary file's byte-pattern fingerprint as a PNG. Resolved exactly + * like GET /api/file. Raw binary bytes never leave the server: only the head is + * read, and only the image computed from it is returned. + */ + get: operations['get_fingerprint_api_fingerprint_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/commit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; + /** Get Commit */ + get: operations['get_commit_api_commit_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/branches': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Branches */ + get: operations['get_branches_api_branches_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/manifest/signature': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Signature */ + get: operations['signature_api_manifest_signature_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/manifest/cached': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Cached Manifest + * @description The newest manifest already on disk for this source, or 404. Never + * scans, never clones, never resolves a ref over the network. + * + * Backs the landing backdrop, which wants a city to show rather than a + * current one. Everything else wants the truth and goes to /api/manifest. + */ + get: operations['cached_manifest_api_manifest_cached_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/manifest': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Manifest */ + get: operations['manifest_api_manifest_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/timeline': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Timeline */ + get: operations['timeline_api_timeline_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { - schemas: { - /** AuthorStat */ - AuthorStat: { - /** Name */ - name: string; - /** Commits */ - commits: number; - /** - * Hue - * @description Stable 0-359 hue from the name hash; the display colour is built from it client-side - */ - hue: number; - }; - /** BranchListResponse */ - BranchListResponse: { - /** Branches */ - branches: string[]; - /** Default */ - default: string | null; - }; - /** BusynessThresholds */ - BusynessThresholds: { - /** Avg */ - avg: number; - /** Busy */ - busy: number; - }; - /** - * CloneProgressEvent - * @description `clone-progress` — git source is being cloned; carries clone progress. - * - * A normal progress tick has `stage` + `percent`, plus git's own - * `objects`/`objects_total`/`mib` where the line carries them. A heartbeat - * during the silent promisor blob fetch instead carries `mb_on_disk` (and no - * percent), so the UI shows the working tree materializing, not a freeze. - */ - CloneProgressEvent: { - /** Label */ - label?: string; - stage?: components["schemas"]["CloneStage"] | null; - /** Percent */ - percent?: number; - /** Mb On Disk */ - mb_on_disk?: number; - /** Objects */ - objects?: number; - /** Objects Total */ - objects_total?: number; - /** Mib */ - mib?: number; - }; - /** - * CloneStage - * @description Which part of a git clone or fetch a progress tick is reporting. - * `updating` is git's checkout phase ("Updating files: N%"). - * @enum {string} - */ - CloneStage: "receiving" | "resolving" | "counting" | "updating"; - /** CommitDateRange */ - CommitDateRange: { - /** - * Oldest - * @description Oldest commit date (YYYY-MM-DD), or null when the repo has no commits - */ - oldest: string | null; - /** - * Newest - * @description Newest commit date (YYYY-MM-DD), or null when the repo has no commits - */ - newest: string | null; - }; - /** CommitDetailResponse */ - CommitDetailResponse: { - /** Sha */ - sha: string; - /** Authors */ - authors: string[]; - /** Date */ - date: string; - /** Subject */ - subject: string; - /** Body */ - body: string; - }; - /** CommitEntry */ - CommitEntry: { - /** - * Date - * @description ISO-8601 UTC, e.g. 2026-07-25T14:03:21Z - */ - date: string; - /** Files */ - files: number; - /** Sha */ - sha: string; - /** Authors */ - authors: string[]; - /** Subject */ - subject: string; - /** Same Day Total */ - same_day_total: number; - }; - /** CommitLeader */ - CommitLeader: { - /** Sha */ - sha: string; - /** Files */ - files: number; - /** Date */ - date: string; - }; - /** - * CompleteManifestEvent - * @description `manifest-complete` — a manifest with real, fully-populated metadata (a - * fresh scan's final pass, or a warm cache hit). - */ - CompleteManifestEvent: { - manifest: components["schemas"]["Manifest"]; - }; - /** ConfigResponse */ - ConfigResponse: { - /** Allowlocalrepos */ - allowLocalRepos: boolean; - /** Hosted */ - hosted: boolean; - /** Version */ - version: string; - /** Featuredrepo */ - featuredRepo: string; - }; - /** - * ContentPendingResponse - * @description 202 body for GET /api/file: the content exists, this machine just doesn't - * have it yet. Deliberately not a 404 — a repo mid-fetch would answer a whole - * page of previews with them, and a burst of 404s from one client is what - * edge proxies read as scanning and start blocking. - */ - ContentPendingResponse: { - /** - * Status - * @default pending - * @constant - */ - status: "pending"; - /** Message */ - message: string; - }; - /** DateRangeMs */ - DateRangeMs: { - /** Mincreated */ - minCreated: number; - /** Maxcreated */ - maxCreated: number; - /** Minmodified */ - minModified: number; - /** Maxmodified */ - maxModified: number; - }; - /** DateRanges */ - DateRanges: { - /** - * Mincreated - * @description Earliest resolved create date (ISO), or null for an empty tree - */ - minCreated: string | null; - /** - * Maxcreated - * @description Latest resolved create date (ISO), or null for an empty tree - */ - maxCreated: string | null; - /** - * Minmodified - * @description Earliest resolved modify date (ISO), or null for an empty tree - */ - minModified: string | null; - /** - * Maxmodified - * @description Latest resolved modify date (ISO), or null for an empty tree - */ - maxModified: string | null; - }; - /** DayLeader */ - DayLeader: { - /** Date */ - date: string; - /** Count */ - count: number; - }; - /** DirLeader */ - DirLeader: { - /** Path */ - path: string; - /** Depth */ - depth: number; - /** Children */ - children: number; - /** Descendants */ - descendants: number; - /** Created */ - created: string | null; - /** Modified */ - modified: string | null; - }; - /** DirNode */ - DirNode: { - /** Name */ - name: string; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "directory"; - /** Path */ - path: string; - /** Children */ - children: (components["schemas"]["FileNode"] | components["schemas"]["DirNode"])[]; - /** Children Count */ - children_count: number; - /** Children File Count */ - children_file_count: number; - /** Children Dir Count */ - children_dir_count: number; - /** Descendants Count */ - descendants_count: number; - /** Descendants File Count */ - descendants_file_count: number; - /** Descendants Dir Count */ - descendants_dir_count: number; - /** Descendants Size */ - descendants_size: number; - /** Descendants Created Min */ - descendants_created_min: string | null; - /** Descendants Modified Max */ - descendants_modified_max: string | null; - /** Descendants Ext Breakdown */ - descendants_ext_breakdown: components["schemas"]["ExtBreakdownEntry"][]; - }; - /** - * DiscoverEntry - * @description One curated repo on the landing's Discover tab. Deliberately just a URL - * and a name: stars and scan timings were considered and rejected, so there - * is nothing here to rot or to fetch from a third party. - */ - DiscoverEntry: { - /** Url */ - url: string; - /** Label */ - label: string; - /** - * Featured - * @default false - */ - featured: boolean; - }; - /** DiscoverResponse */ - DiscoverResponse: { - /** Repos */ - repos: components["schemas"]["DiscoverEntry"][]; - }; - /** - * ErrorCode - * @description Machine-readable discriminator on an `error` event or error response. - * The client keys its remedy on this, never on the message text. Only - * failures the UI answers differently earn a member; everything else stays - * message-only. - * @enum {string} - */ - ErrorCode: "repo-not-found"; - /** - * ErrorEvent - * @description `error` — a failure after the stream began; carries the message and, - * where the UI can act on the reason, a code. - */ - ErrorEvent: { - /** Error */ - error: string; - /** @enum {string} */ - code?: "repo-not-found"; - }; - /** ExtBreakdownEntry */ - ExtBreakdownEntry: { - /** Ext */ - ext: string | null; - /** Count */ - count: number; - /** Size */ - size: number; - }; - /** FileLeader */ - FileLeader: { - /** Path */ - path: string; - /** Lines */ - lines: number; - /** Bytes */ - bytes: number; - /** Created */ - created: string; - /** Modified */ - modified: string; - /** Media Width */ - media_width?: number; - /** Media Height */ - media_height?: number; - }; - /** FileNode */ - FileNode: { - /** Name */ - name: string; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "file"; - /** Path */ - path: string; - /** Extension */ - extension: string; - /** Size */ - size: number | null; - /** Lines */ - lines: number | null; - /** Binary */ - binary: boolean; - /** - * Dirty - * @description Working-tree differs from HEAD for this tracked file (staged or unstaged). Always False for clean/remote repos. - */ - dirty: boolean; - /** - * Created - * @description ISO create date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise - */ - created: string; - /** - * Modified - * @description ISO modify date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise. When dirty is true, this is always the working-tree filesystem date, regardless of git history - */ - modified: string; - /** - * Mediakind - * @description Media classification by extension (single source for the frontend); null for non-media files - */ - mediaKind?: ("image" | "video") | null; - /** Media Width */ - media_width?: number; - /** Media Height */ - media_height?: number; - /** Binarytype */ - binaryType?: string; - }; - /** HTTPValidationError */ - HTTPValidationError: { - /** Detail */ - detail?: components["schemas"]["ValidationError"][]; - }; - /** HealthResponse */ - HealthResponse: { - /** Ok */ - ok: boolean; - }; - /** Manifest */ - Manifest: { - /** - * Src - * @description The source this describes, as passed to /api/manifest. Every path in the manifest is relative to it, and reads send it back so the server can resolve the root again (api/routers/README.md). - */ - src: string; - /** - * Branch - * @description Branch as passed alongside `src`, or null if none was - */ - branch: string | null; - /** Scanned At */ - scanned_at: string; - /** Content Signature */ - content_signature: string; - /** Structure Signature */ - structure_signature: string; - /** Layout Signature */ - layout_signature: string; - tree: components["schemas"]["DirNode"]; - repo: components["schemas"]["RepoInfo"]; - /** - * Commits - * @description Oldest-first. Above 100k, an evenly strided sample of the history; stats.commitCount is the true total. - */ - commits: components["schemas"]["CommitEntry"][]; - busyness: components["schemas"]["BusynessThresholds"]; - dateRanges: components["schemas"]["DateRanges"]; - stats: components["schemas"]["RepoStats"]; - /** - * Pending - * @description Stages still to come. 'metadata': per-file lines/binary are placeholders. 'history': dates are filesystem dates and commits is empty. Empty list means every field is final. - */ - pending: ("metadata" | "history")[]; - /** - * Readmepath - * @description Repo-relative path of the root README, or null if there isn't one - */ - readmePath: string | null; - /** - * Readmemodified - * @description That README's mtime, for cache-busting the fetch - */ - readmeModified: string | null; - }; - /** - * PartialManifestEvent - * @description `manifest-partial` — a manifest with the real tree structure but - * placeholder file metadata, sent so the UI can paint the city before - * per-file metadata is resolved. - */ - PartialManifestEvent: { - manifest: components["schemas"]["Manifest"]; - }; - /** RangeStat */ - RangeStat: { - /** Min */ - min: number; - /** Max */ - max: number; - }; - /** RepoInfo */ - RepoInfo: { - /** Branch */ - branch: string | null; - /** Remote Url */ - remote_url: string | null; - /** Head Sha */ - head_sha: string | null; - /** Head Subject */ - head_subject: string | null; - /** Dirty */ - dirty: boolean; - }; - /** RepoStats */ - RepoStats: { - lineCountRange: components["schemas"]["RangeStat"]; - byteSizeRange: components["schemas"]["RangeStat"]; - oldestCreatedFile: components["schemas"]["FileLeader"] | null; - newestCreatedFile: components["schemas"]["FileLeader"] | null; - newestModifiedFile: components["schemas"]["FileLeader"] | null; - oldestModifiedFile: components["schemas"]["FileLeader"] | null; - maxLinesFile: components["schemas"]["FileLeader"] | null; - minLinesFile: components["schemas"]["FileLeader"] | null; - maxBytesFile: components["schemas"]["FileLeader"] | null; - minBytesFile: components["schemas"]["FileLeader"] | null; - maxMediaBytesFile: components["schemas"]["FileLeader"] | null; - minMediaBytesFile: components["schemas"]["FileLeader"] | null; - maxMediaPixelsFile: components["schemas"]["FileLeader"] | null; - minMediaPixelsFile: components["schemas"]["FileLeader"] | null; - maxBinaryBytesFile: components["schemas"]["FileLeader"] | null; - minBinaryBytesFile: components["schemas"]["FileLeader"] | null; - /** Mediacount */ - mediaCount: number; - /** Binarycount */ - binaryCount: number; - /** Totallines */ - totalLines: number; - /** Dirtyfilecount */ - dirtyFileCount: number; - /** Codebytes */ - codeBytes: number; - maxDepthDir: components["schemas"]["DirLeader"] | null; - maxChildrenDir: components["schemas"]["DirLeader"] | null; - minChildrenDir: components["schemas"]["DirLeader"] | null; - oldestCreatedDir: components["schemas"]["DirLeader"] | null; - newestCreatedDir: components["schemas"]["DirLeader"] | null; - maxFilesPerCommit: components["schemas"]["CommitLeader"] | null; - minFilesPerCommit: components["schemas"]["CommitLeader"] | null; - oldestCommit: components["schemas"]["CommitLeader"] | null; - newestCommit: components["schemas"]["CommitLeader"] | null; - /** - * Commitcount - * @description Commits in the full history, however many `commits` carries - */ - commitCount: number; - commitDates: components["schemas"]["CommitDateRange"]; - maxCommitsPerDay: components["schemas"]["DayLeader"] | null; - /** Maxcommitstreakdays */ - maxCommitStreakDays: number; - /** Authors */ - authors: components["schemas"]["AuthorStat"][]; - }; - /** - * ScanEvent - * @description SSE event names for the /api/manifest stream. Values are the exact event - * strings. The two MANIFEST_* members double as scan_tree's emission `phase` - * (the router forwards the phase as the event name). - * @enum {string} - */ - ScanEvent: "clone-progress" | "scan-progress" | "manifest-partial" | "manifest-complete" | "error"; - /** - * ScanProgressEvent - * @description `scan-progress` — the working tree is being walked; carries the - * heartbeat files-scanned count. - */ - ScanProgressEvent: { - /** Label */ - label?: string; - /** Files Scanned */ - files_scanned?: number; - }; - /** - * ScanStreamMessage - * @description One message on the /api/manifest stream: its SSE `event:` name and the - * JSON `data:` body that follows. - */ - ScanStreamMessage: { - event: components["schemas"]["ScanEvent"]; - /** Data */ - data: components["schemas"]["CloneProgressEvent"] | components["schemas"]["ScanProgressEvent"] | components["schemas"]["PartialManifestEvent"] | components["schemas"]["CompleteManifestEvent"] | components["schemas"]["ErrorEvent"]; - }; - /** SignatureResponse */ - SignatureResponse: { - /** Scanned At */ - scanned_at: string; - /** Content Signature */ - content_signature: string; - }; - /** - * TimelineBundle - * @description Everything the client replays for smooth commit scrubbing: commit list, - * union-of-all-paths manifest (the layout target), per-commit blob deltas, - * sha -> line-count and sha -> byte-size tables, and per-commit line ranges - * (height normalisation, so a scrub point matches Live-at-that-commit). - * `notes` are standing caveats about the bundle: a pathological repo windowed - * to its most recent commits, blobs too large to have been backfilled. A list - * because they are independent and a repo can earn both. - */ - TimelineBundle: { - /** Commits */ - commits: components["schemas"]["CommitEntry"][]; - unionManifest: components["schemas"]["Manifest"]; - /** Deltas */ - deltas: components["schemas"]["TimelineDelta"][]; - /** Bloblines */ - blobLines: { - [key: string]: number | null; - }; - /** Blobsizes */ - blobSizes: { - [key: string]: number | null; - }; - /** Commitlineranges */ - commitLineRanges: components["schemas"]["RangeStat"][]; - /** Commitdateranges */ - commitDateRanges: components["schemas"]["DateRangeMs"][]; - /** Notes */ - notes: string[]; - }; - /** TimelineChange */ - TimelineChange: { - /** Path */ - path: string; - /** - * Sha - * @description New blob sha, or null when deleted - */ - sha: string | null; - }; - /** - * TimelineCompleteEvent - * @description `timeline-complete` — the full replay bundle (fresh build or warm - * cache hit). - */ - TimelineCompleteEvent: { - bundle: components["schemas"]["TimelineBundle"]; - }; - /** TimelineDelta */ - TimelineDelta: { - /** Sha */ - sha: string; - /** Changes */ - changes: components["schemas"]["TimelineChange"][]; - }; - /** - * TimelineEvent - * @description SSE event names for the /api/timeline stream. - * @enum {string} - */ - TimelineEvent: "timeline-progress" | "timeline-complete" | "error"; - /** - * TimelineProgressEvent - * @description `timeline-progress` — the history walk, blob-table resolution, union - * assembly, or (for a blobless remote clone) the up-front blob backfill is in - * progress. The `fetch` stage carries `percent`; `history` carries `commits`; - * `blobs` carries `blobsDone`/`blobsTotal` (the total is known up front from - * the batch blob lookup, so that stage reports two ticks, not a live stream); - * `assemble` carries `percent` over its own steps and runs until the bundle - * lands. - */ - TimelineProgressEvent: { - /** @enum {string} */ - stage: "fetch" | "history" | "blobs" | "assemble"; - /** Percent */ - percent?: number; - /** Objects */ - objects?: number; - /** Objectstotal */ - objectsTotal?: number; - /** Mib */ - mib?: number; - /** Commits */ - commits?: number; - /** Blobsdone */ - blobsDone?: number; - /** Blobstotal */ - blobsTotal?: number; - /** Label */ - label?: string; - }; - /** - * TimelineStage - * @description Which part of the timeline build a progress tick is reporting. - * @enum {string} - */ - TimelineStage: "fetch" | "history" | "blobs" | "assemble"; - /** - * TimelineStreamMessage - * @description One message on the /api/timeline stream: its SSE `event:` name and the - * JSON `data:` body that follows. - */ - TimelineStreamMessage: { - event: components["schemas"]["TimelineEvent"]; - /** Data */ - data: components["schemas"]["TimelineProgressEvent"] | components["schemas"]["TimelineCompleteEvent"] | components["schemas"]["ErrorEvent"]; - }; - /** ValidationError */ - ValidationError: { - /** Location */ - loc: (string | number)[]; - /** Message */ - msg: string; - /** Error Type */ - type: string; - /** Input */ - input?: unknown; - /** Context */ - ctx?: Record; - }; + schemas: { + /** AuthorStat */ + AuthorStat: { + /** Name */ + name: string; + /** Commits */ + commits: number; + /** + * Hue + * @description Stable 0-359 hue from the name hash; the display colour is built from it client-side + */ + hue: number; + }; + /** BranchListResponse */ + BranchListResponse: { + /** Branches */ + branches: string[]; + /** Default */ + default: string | null; + }; + /** BusynessThresholds */ + BusynessThresholds: { + /** Avg */ + avg: number; + /** Busy */ + busy: number; + }; + /** + * CloneProgressEvent + * @description `clone-progress` — git source is being cloned; carries clone progress. + * + * A normal progress tick has `stage` + `percent`, plus git's own + * `objects`/`objects_total`/`mib` where the line carries them. A heartbeat + * during the silent promisor blob fetch instead carries `mb_on_disk` (and no + * percent), so the UI shows the working tree materializing, not a freeze. + */ + CloneProgressEvent: { + /** Label */ + label?: string; + stage?: components['schemas']['CloneStage'] | null; + /** Percent */ + percent?: number; + /** Mb On Disk */ + mb_on_disk?: number; + /** Objects */ + objects?: number; + /** Objects Total */ + objects_total?: number; + /** Mib */ + mib?: number; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + /** + * CloneStage + * @description Which part of a git clone or fetch a progress tick is reporting. + * `updating` is git's checkout phase ("Updating files: N%"). + * @enum {string} + */ + CloneStage: 'receiving' | 'resolving' | 'counting' | 'updating'; + /** CommitDateRange */ + CommitDateRange: { + /** + * Oldest + * @description Oldest commit date (YYYY-MM-DD), or null when the repo has no commits + */ + oldest: string | null; + /** + * Newest + * @description Newest commit date (YYYY-MM-DD), or null when the repo has no commits + */ + newest: string | null; + }; + /** CommitDetailResponse */ + CommitDetailResponse: { + /** Sha */ + sha: string; + /** Authors */ + authors: string[]; + /** Date */ + date: string; + /** Subject */ + subject: string; + /** Body */ + body: string; + }; + /** CommitEntry */ + CommitEntry: { + /** + * Date + * @description ISO-8601 UTC, e.g. 2026-07-25T14:03:21Z + */ + date: string; + /** Files */ + files: number; + /** Sha */ + sha: string; + /** Authors */ + authors: string[]; + /** Subject */ + subject: string; + /** Same Day Total */ + same_day_total: number; + }; + /** CommitLeader */ + CommitLeader: { + /** Sha */ + sha: string; + /** Files */ + files: number; + /** Date */ + date: string; + }; + /** + * CompleteManifestEvent + * @description `manifest-complete` — a manifest with real, fully-populated metadata (a + * fresh scan's final pass, or a warm cache hit). + */ + CompleteManifestEvent: { + manifest: components['schemas']['Manifest']; + }; + /** ConfigResponse */ + ConfigResponse: { + /** Allowlocalrepos */ + allowLocalRepos: boolean; + /** Hosted */ + hosted: boolean; + /** Version */ + version: string; + /** Featuredrepo */ + featuredRepo: string; + }; + /** + * ContentPendingResponse + * @description 202 body for GET /api/file: the content exists, this machine just doesn't + * have it yet. Deliberately not a 404 — a repo mid-fetch would answer a whole + * page of previews with them, and a burst of 404s from one client is what + * edge proxies read as scanning and start blocking. + */ + ContentPendingResponse: { + /** + * Status + * @default pending + * @constant + */ + status: 'pending'; + /** Message */ + message: string; + }; + /** DateRangeMs */ + DateRangeMs: { + /** Mincreated */ + minCreated: number; + /** Maxcreated */ + maxCreated: number; + /** Minmodified */ + minModified: number; + /** Maxmodified */ + maxModified: number; + }; + /** DateRanges */ + DateRanges: { + /** + * Mincreated + * @description Earliest resolved create date (ISO), or null for an empty tree + */ + minCreated: string | null; + /** + * Maxcreated + * @description Latest resolved create date (ISO), or null for an empty tree + */ + maxCreated: string | null; + /** + * Minmodified + * @description Earliest resolved modify date (ISO), or null for an empty tree + */ + minModified: string | null; + /** + * Maxmodified + * @description Latest resolved modify date (ISO), or null for an empty tree + */ + maxModified: string | null; + }; + /** DayLeader */ + DayLeader: { + /** Date */ + date: string; + /** Count */ + count: number; + }; + /** DirLeader */ + DirLeader: { + /** Path */ + path: string; + /** Depth */ + depth: number; + /** Children */ + children: number; + /** Descendants */ + descendants: number; + /** Created */ + created: string | null; + /** Modified */ + modified: string | null; + }; + /** DirNode */ + DirNode: { + /** Name */ + name: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'directory'; + /** Path */ + path: string; + /** Children */ + children: (components['schemas']['FileNode'] | components['schemas']['DirNode'])[]; + /** Children Count */ + children_count: number; + /** Children File Count */ + children_file_count: number; + /** Children Dir Count */ + children_dir_count: number; + /** Descendants Count */ + descendants_count: number; + /** Descendants File Count */ + descendants_file_count: number; + /** Descendants Dir Count */ + descendants_dir_count: number; + /** Descendants Size */ + descendants_size: number; + /** Descendants Created Min */ + descendants_created_min: string | null; + /** Descendants Modified Max */ + descendants_modified_max: string | null; + /** Descendants Ext Breakdown */ + descendants_ext_breakdown: components['schemas']['ExtBreakdownEntry'][]; + }; + /** + * DiscoverEntry + * @description One curated repo on the landing's Discover tab. Deliberately just a URL + * and a name: stars and scan timings were considered and rejected, so there + * is nothing here to rot or to fetch from a third party. + */ + DiscoverEntry: { + /** Url */ + url: string; + /** Label */ + label: string; + /** + * Featured + * @default false + */ + featured: boolean; + }; + /** DiscoverResponse */ + DiscoverResponse: { + /** Repos */ + repos: components['schemas']['DiscoverEntry'][]; + }; + /** + * ErrorCode + * @description Machine-readable discriminator on an `error` event or error response. + * The client keys its remedy on this, never on the message text. Only + * failures the UI answers differently earn a member; everything else stays + * message-only. + * @enum {string} + */ + ErrorCode: 'repo-not-found'; + /** + * ErrorEvent + * @description `error` — a failure after the stream began; carries the message and, + * where the UI can act on the reason, a code. + */ + ErrorEvent: { + /** Error */ + error: string; + /** @enum {string} */ + code?: 'repo-not-found'; + }; + /** ExtBreakdownEntry */ + ExtBreakdownEntry: { + /** Ext */ + ext: string | null; + /** Count */ + count: number; + /** Size */ + size: number; + }; + /** FileLeader */ + FileLeader: { + /** Path */ + path: string; + /** Lines */ + lines: number; + /** Bytes */ + bytes: number; + /** Created */ + created: string; + /** Modified */ + modified: string; + /** Media Width */ + media_width?: number; + /** Media Height */ + media_height?: number; + }; + /** FileNode */ + FileNode: { + /** Name */ + name: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'file'; + /** Path */ + path: string; + /** Extension */ + extension: string; + /** Size */ + size: number | null; + /** Lines */ + lines: number | null; + /** Binary */ + binary: boolean; + /** + * Dirty + * @description Working-tree differs from HEAD for this tracked file (staged or unstaged). Always False for clean/remote repos. + */ + dirty: boolean; + /** + * Created + * @description ISO create date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise + */ + created: string; + /** + * Modified + * @description ISO modify date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise. When dirty is true, this is always the working-tree filesystem date, regardless of git history + */ + modified: string; + /** + * Mediakind + * @description Media classification by extension (single source for the frontend); null for non-media files + */ + mediaKind?: ('image' | 'video') | null; + /** Media Width */ + media_width?: number; + /** Media Height */ + media_height?: number; + /** Binarytype */ + binaryType?: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components['schemas']['ValidationError'][]; + }; + /** HealthResponse */ + HealthResponse: { + /** Ok */ + ok: boolean; + }; + /** Manifest */ + Manifest: { + /** + * Src + * @description The source this describes, as passed to /api/manifest. Every path in the manifest is relative to it, and reads send it back so the server can resolve the root again (api/routers/README.md). + */ + src: string; + /** + * Branch + * @description Branch as passed alongside `src`, or null if none was + */ + branch: string | null; + /** Scanned At */ + scanned_at: string; + /** Content Signature */ + content_signature: string; + /** Structure Signature */ + structure_signature: string; + /** Layout Signature */ + layout_signature: string; + tree: components['schemas']['DirNode']; + repo: components['schemas']['RepoInfo']; + /** + * Commits + * @description Oldest-first. Above 100k, an evenly strided sample of the history; stats.commitCount is the true total. + */ + commits: components['schemas']['CommitEntry'][]; + busyness: components['schemas']['BusynessThresholds']; + dateRanges: components['schemas']['DateRanges']; + stats: components['schemas']['RepoStats']; + /** + * Pending + * @description Stages still to come. 'metadata': per-file lines/binary are placeholders. 'history': dates are filesystem dates and commits is empty. Empty list means every field is final. + */ + pending: ('metadata' | 'history')[]; + /** + * Readmepath + * @description Repo-relative path of the root README, or null if there isn't one + */ + readmePath: string | null; + /** + * Readmemodified + * @description That README's mtime, for cache-busting the fetch + */ + readmeModified: string | null; + }; + /** + * PartialManifestEvent + * @description `manifest-partial` — a manifest with the real tree structure but + * placeholder file metadata, sent so the UI can paint the city before + * per-file metadata is resolved. + */ + PartialManifestEvent: { + manifest: components['schemas']['Manifest']; + }; + /** RangeStat */ + RangeStat: { + /** Min */ + min: number; + /** Max */ + max: number; + }; + /** RepoInfo */ + RepoInfo: { + /** Branch */ + branch: string | null; + /** Remote Url */ + remote_url: string | null; + /** Head Sha */ + head_sha: string | null; + /** Head Subject */ + head_subject: string | null; + /** Dirty */ + dirty: boolean; + }; + /** RepoStats */ + RepoStats: { + lineCountRange: components['schemas']['RangeStat']; + byteSizeRange: components['schemas']['RangeStat']; + oldestCreatedFile: components['schemas']['FileLeader'] | null; + newestCreatedFile: components['schemas']['FileLeader'] | null; + newestModifiedFile: components['schemas']['FileLeader'] | null; + oldestModifiedFile: components['schemas']['FileLeader'] | null; + maxLinesFile: components['schemas']['FileLeader'] | null; + minLinesFile: components['schemas']['FileLeader'] | null; + maxBytesFile: components['schemas']['FileLeader'] | null; + minBytesFile: components['schemas']['FileLeader'] | null; + maxMediaBytesFile: components['schemas']['FileLeader'] | null; + minMediaBytesFile: components['schemas']['FileLeader'] | null; + maxMediaPixelsFile: components['schemas']['FileLeader'] | null; + minMediaPixelsFile: components['schemas']['FileLeader'] | null; + maxBinaryBytesFile: components['schemas']['FileLeader'] | null; + minBinaryBytesFile: components['schemas']['FileLeader'] | null; + /** Mediacount */ + mediaCount: number; + /** Binarycount */ + binaryCount: number; + /** Totallines */ + totalLines: number; + /** Dirtyfilecount */ + dirtyFileCount: number; + /** Codebytes */ + codeBytes: number; + maxDepthDir: components['schemas']['DirLeader'] | null; + maxChildrenDir: components['schemas']['DirLeader'] | null; + minChildrenDir: components['schemas']['DirLeader'] | null; + oldestCreatedDir: components['schemas']['DirLeader'] | null; + newestCreatedDir: components['schemas']['DirLeader'] | null; + maxFilesPerCommit: components['schemas']['CommitLeader'] | null; + minFilesPerCommit: components['schemas']['CommitLeader'] | null; + oldestCommit: components['schemas']['CommitLeader'] | null; + newestCommit: components['schemas']['CommitLeader'] | null; + /** + * Commitcount + * @description Commits in the full history, however many `commits` carries + */ + commitCount: number; + commitDates: components['schemas']['CommitDateRange']; + maxCommitsPerDay: components['schemas']['DayLeader'] | null; + /** Maxcommitstreakdays */ + maxCommitStreakDays: number; + /** Authors */ + authors: components['schemas']['AuthorStat'][]; + }; + /** + * ScanEvent + * @description SSE event names for the /api/manifest stream. Values are the exact event + * strings. The two MANIFEST_* members double as scan_tree's emission `phase` + * (the router forwards the phase as the event name). + * @enum {string} + */ + ScanEvent: + 'clone-progress' | 'scan-progress' | 'manifest-partial' | 'manifest-complete' | 'error'; + /** + * ScanProgressEvent + * @description `scan-progress` — the working tree is being walked; carries the + * heartbeat files-scanned count. + */ + ScanProgressEvent: { + /** Label */ + label?: string; + /** Files Scanned */ + files_scanned?: number; + }; + /** + * ScanStreamMessage + * @description One message on the /api/manifest stream: its SSE `event:` name and the + * JSON `data:` body that follows. + */ + ScanStreamMessage: { + event: components['schemas']['ScanEvent']; + /** Data */ + data: + | components['schemas']['CloneProgressEvent'] + | components['schemas']['ScanProgressEvent'] + | components['schemas']['PartialManifestEvent'] + | components['schemas']['CompleteManifestEvent'] + | components['schemas']['ErrorEvent']; + }; + /** SignatureResponse */ + SignatureResponse: { + /** Scanned At */ + scanned_at: string; + /** Content Signature */ + content_signature: string; + }; + /** + * TimelineBundle + * @description Everything the client replays for smooth commit scrubbing: commit list, + * union-of-all-paths manifest (the layout target), per-commit blob deltas, + * sha -> line-count and sha -> byte-size tables, and per-commit line ranges + * (height normalisation, so a scrub point matches Live-at-that-commit). + * `notes` are standing caveats about the bundle: a pathological repo windowed + * to its most recent commits, blobs too large to have been backfilled. A list + * because they are independent and a repo can earn both. + */ + TimelineBundle: { + /** Commits */ + commits: components['schemas']['CommitEntry'][]; + unionManifest: components['schemas']['Manifest']; + /** Deltas */ + deltas: components['schemas']['TimelineDelta'][]; + /** Bloblines */ + blobLines: { + [key: string]: number | null; + }; + /** Blobsizes */ + blobSizes: { + [key: string]: number | null; + }; + /** Commitlineranges */ + commitLineRanges: components['schemas']['RangeStat'][]; + /** Commitdateranges */ + commitDateRanges: components['schemas']['DateRangeMs'][]; + /** Notes */ + notes: string[]; + }; + /** TimelineChange */ + TimelineChange: { + /** Path */ + path: string; + /** + * Sha + * @description New blob sha, or null when deleted + */ + sha: string | null; + }; + /** + * TimelineCompleteEvent + * @description `timeline-complete` — the full replay bundle (fresh build or warm + * cache hit). + */ + TimelineCompleteEvent: { + bundle: components['schemas']['TimelineBundle']; + }; + /** TimelineDelta */ + TimelineDelta: { + /** Sha */ + sha: string; + /** Changes */ + changes: components['schemas']['TimelineChange'][]; + }; + /** + * TimelineEvent + * @description SSE event names for the /api/timeline stream. + * @enum {string} + */ + TimelineEvent: 'timeline-progress' | 'timeline-complete' | 'error'; + /** + * TimelineProgressEvent + * @description `timeline-progress` — the history walk, blob-table resolution, union + * assembly, or (for a blobless remote clone) the up-front blob backfill is in + * progress. The `fetch` stage carries `percent`; `history` carries `commits`; + * `blobs` carries `blobsDone`/`blobsTotal` (the total is known up front from + * the batch blob lookup, so that stage reports two ticks, not a live stream); + * `assemble` carries `percent` over its own steps and runs until the bundle + * lands. + */ + TimelineProgressEvent: { + /** @enum {string} */ + stage: 'fetch' | 'history' | 'blobs' | 'assemble'; + /** Percent */ + percent?: number; + /** Objects */ + objects?: number; + /** Objectstotal */ + objectsTotal?: number; + /** Mib */ + mib?: number; + /** Commits */ + commits?: number; + /** Blobsdone */ + blobsDone?: number; + /** Blobstotal */ + blobsTotal?: number; + /** Label */ + label?: string; + }; + /** + * TimelineStage + * @description Which part of the timeline build a progress tick is reporting. + * @enum {string} + */ + TimelineStage: 'fetch' | 'history' | 'blobs' | 'assemble'; + /** + * TimelineStreamMessage + * @description One message on the /api/timeline stream: its SSE `event:` name and the + * JSON `data:` body that follows. + */ + TimelineStreamMessage: { + event: components['schemas']['TimelineEvent']; + /** Data */ + data: + | components['schemas']['TimelineProgressEvent'] + | components['schemas']['TimelineCompleteEvent'] + | components['schemas']['ErrorEvent']; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; } export type $defs = Record; export interface operations { - health_api_health_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HealthResponse"]; - }; - }; - }; + health_api_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - config_api_config_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ConfigResponse"]; - }; - }; + content: { + 'application/json': components['schemas']['HealthResponse']; }; + }; }; - discover_api_discover_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DiscoverResponse"]; - }; - }; - }; + }; + config_api_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - get_file_api_file_get: { - parameters: { - query: { - /** @description The manifest's `src`: which repo to read from */ - src: string; - /** @description The manifest's `branch`, as it was passed to /api/manifest */ - branch?: string | null; - /** @description Path relative to that repo's root */ - path: string; - /** @description Blob sha to read instead of the working tree */ - sha?: string | null; - /** @description Version marker; never read, only cached against */ - mtime?: string | null; - }; - header?: never; - path?: never; - cookie?: never; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Content not downloaded yet; retry later. */ - 202: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ContentPendingResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; + content: { + 'application/json': components['schemas']['ConfigResponse']; }; + }; }; - get_fingerprint_api_fingerprint_get: { - parameters: { - query: { - /** @description The manifest's `src`: which repo to read from */ - src: string; - /** @description The manifest's `branch`, as it was passed to /api/manifest */ - branch?: string | null; - /** @description Path relative to that repo's root */ - path: string; - /** @description Version marker; never read, only cached against */ - mtime?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Content not downloaded yet; retry later. */ - 202: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ContentPendingResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + discover_api_discover_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - get_commit_api_commit_get: { - parameters: { - query: { - /** @description The manifest's `src`: whose history to read */ - src: string; - /** @description The manifest's `branch`, as it was passed to /api/manifest */ - branch?: string | null; - sha: string; - }; - header?: never; - path?: never; - cookie?: never; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CommitDetailResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; + content: { + 'application/json': components['schemas']['DiscoverResponse']; }; + }; }; - get_branches_api_branches_get: { - parameters: { - query: { - src: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BranchListResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + get_file_api_file_get: { + parameters: { + query: { + /** @description The manifest's `src`: which repo to read from */ + src: string; + /** @description The manifest's `branch`, as it was passed to /api/manifest */ + branch?: string | null; + /** @description Path relative to that repo's root */ + path: string; + /** @description Blob sha to read instead of the working tree */ + sha?: string | null; + /** @description Version marker; never read, only cached against */ + mtime?: string | null; + }; + header?: never; + path?: never; + cookie?: never; }; - signature_api_manifest_signature_get: { - parameters: { - query: { - src: string; - branch?: string | null; - no_cache?: boolean; - exclude?: string[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SignatureResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Content not downloaded yet; retry later. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ContentPendingResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; }; - cached_manifest_api_manifest_cached_get: { - parameters: { - query: { - src: string; - branch?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Manifest"]; - }; - }; - /** @description Nothing cached for this source. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + get_fingerprint_api_fingerprint_get: { + parameters: { + query: { + /** @description The manifest's `src`: which repo to read from */ + src: string; + /** @description The manifest's `branch`, as it was passed to /api/manifest */ + branch?: string | null; + /** @description Path relative to that repo's root */ + path: string; + /** @description Version marker; never read, only cached against */ + mtime?: string | null; + }; + header?: never; + path?: never; + cookie?: never; }; - manifest_api_manifest_get: { - parameters: { - query?: { - src?: string; - branch?: string | null; - no_cache?: boolean; - exclude?: string[]; - ref?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `clone-progress` (CloneProgressEvent), `scan-progress` (ScanProgressEvent), `manifest-partial` (PartialManifestEvent), `manifest-complete` (CompleteManifestEvent), `error` (ErrorEvent). The client closes the connection on `manifest-complete`/`error`. When `ref` is set, the manifest is reconstructed as of that commit instead of the working tree (a remote source still emits `clone-progress` if it isn't cloned yet, but never `scan-progress`/`manifest-partial` for the reconstruction itself — the city is already drawn, so a skeleton would flash placeholders). */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ScanStreamMessage"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Content not downloaded yet; retry later. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ContentPendingResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; }; - timeline_api_timeline_get: { - parameters: { - query: { - src: string; - branch?: string | null; - no_cache?: boolean; - exclude?: string[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `timeline-progress` (TimelineProgressEvent, one or more while the history walk / blob resolution run), `timeline-complete` (TimelineCompleteEvent, the full bundle), `error` (ErrorEvent). A warm cache hit emits only `timeline-complete`, no progress. The client closes the connection on `timeline-complete`/`error`. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TimelineStreamMessage"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + get_commit_api_commit_get: { + parameters: { + query: { + /** @description The manifest's `src`: whose history to read */ + src: string; + /** @description The manifest's `branch`, as it was passed to /api/manifest */ + branch?: string | null; + sha: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommitDetailResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + get_branches_api_branches_get: { + parameters: { + query: { + src: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BranchListResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + signature_api_manifest_signature_get: { + parameters: { + query: { + src: string; + branch?: string | null; + no_cache?: boolean; + exclude?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SignatureResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + cached_manifest_api_manifest_cached_get: { + parameters: { + query: { + src: string; + branch?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Manifest']; + }; + }; + /** @description Nothing cached for this source. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + manifest_api_manifest_get: { + parameters: { + query?: { + src?: string; + branch?: string | null; + no_cache?: boolean; + exclude?: string[]; + ref?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `clone-progress` (CloneProgressEvent), `scan-progress` (ScanProgressEvent), `manifest-partial` (PartialManifestEvent), `manifest-complete` (CompleteManifestEvent), `error` (ErrorEvent). The client closes the connection on `manifest-complete`/`error`. When `ref` is set, the manifest is reconstructed as of that commit instead of the working tree (a remote source still emits `clone-progress` if it isn't cloned yet, but never `scan-progress`/`manifest-partial` for the reconstruction itself — the city is already drawn, so a skeleton would flash placeholders). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ScanStreamMessage']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + timeline_api_timeline_get: { + parameters: { + query: { + src: string; + branch?: string | null; + no_cache?: boolean; + exclude?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `timeline-progress` (TimelineProgressEvent, one or more while the history walk / blob resolution run), `timeline-complete` (TimelineCompleteEvent, the full bundle), `error` (ErrorEvent). A warm cache hit emits only `timeline-complete`, no progress. The client closes the connection on `timeline-complete`/`error`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimelineStreamMessage']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; }; + }; } diff --git a/app/src/views/CityView/CityView.tsx b/app/src/views/CityView/CityView.tsx index e8ebb348..45e5bbe0 100644 --- a/app/src/views/CityView/CityView.tsx +++ b/app/src/views/CityView/CityView.tsx @@ -16,38 +16,28 @@ import { LoadingOverlay } from '@/components/loading/LoadingOverlay/LoadingOverl import { HljsThemeLink } from '@/views/CityView/HljsThemeLink/HljsThemeLink'; import { SelectionAnnouncer } from '@/views/CityView/SelectionAnnouncer/SelectionAnnouncer'; import { useShortcutsKey } from '@/hooks/useShortcutsKey'; -import { cancelLoad, refreshCurrentSource } from '@/hooks/useManifestSource'; -import { attachViewUrlReactions } from '@/router/viewBinding'; import { navigate } from '@/router/location'; import { ROUTES } from '@/router/paths'; -import { LOADING_CANCEL } from '@/state/stores/progress'; -import { CURRENT_SOURCE, clearSourceUrl } from '@/state/stores/source'; -import { - clearSelection, - runCollisionCheck, - runStemDiagnostic, - runTreeGroundingCheck, -} from '@/city/sceneHandle'; +import { clearSourceUrl } from '@/router/urlBinding'; +import { useProject } from '@/state/project/context'; export function CityView() { + const session = useProject(); + const { source, progress, commands, load } = session; // The panel it opens lives in this view's footer, so the key belongs here. useShortcutsKey(); - // Mode, scrub commit and selection are this view's: the landing has nothing - // to describe, and reflecting there would write them onto `/`. - useEffect(() => attachViewUrlReactions(), []); - // A newly loaded source has nothing selected in it yet. useSignalEffect(() => { - if (CURRENT_SOURCE.value) clearSelection(); + if (source.current.value) commands.clearSelection(); }); const onCancelLoad = () => { // A load with something to go back to registers its own handler; one with // nothing to go back to leaves the URL describing what it just called off. - const registered = LOADING_CANCEL.peek(); + const registered = progress.cancel.peek(); if (registered) registered(); else { - cancelLoad(); + load.cancel(); clearSourceUrl(); } }; @@ -59,16 +49,16 @@ export function CityView() { {/* The header owns the control; which read a refresh means in the mode you are in is the fetch layer's call. */} - navigate(ROUTES.HOME)} onRefresh={refreshCurrentSource} /> + navigate(ROUTES.HOME)} onRefresh={load.refresh} />
{/* Belongs to this route: a load started from the landing shows its progress there, in the card it was started from. */} diff --git a/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx b/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx index 9f0a1951..5167daa9 100644 --- a/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx +++ b/app/src/views/CityView/SelectionAnnouncer/SelectionAnnouncer.tsx @@ -2,7 +2,7 @@ // live region speaking the city selection to screen readers, since the canvas is // a graphic and cannot announce its own (WCAG 4.1.3). import { useComputed } from '@preact/signals'; -import { SCENE_HANDLE } from '@/city/sceneHandle'; +import { useProject } from '@/state/project/context'; import { NodeKind } from '@/types'; import type { PickTarget } from '@/types'; @@ -21,11 +21,12 @@ function describe(sel: PickTarget | null): string { } export function SelectionAnnouncer() { + const { city } = useProject(); // The computed dedupes an equal string, so re-resolving the same selection // across a rebuild does not re-announce it. const message = useComputed(() => { - const handle = SCENE_HANDLE.value; - return handle ? describe(handle.picker.selection.value) : ''; + const made = city.value; + return made ? describe(made.picker.selection.value) : ''; }); return (
diff --git a/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx b/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx index 2bffef9f..b37e3186 100644 --- a/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx +++ b/app/src/views/CityView/chrome/CityHeader/CityHeader.tsx @@ -5,8 +5,6 @@ import './CityHeader.css'; import { ExternalLink } from 'lucide-preact'; -import { SOURCE_INFO } from '@/state/stores/source'; -import { MANIFEST } from '@/state/stores/manifest'; import type { Manifest } from '@/types'; import { navigate } from '@/router/location'; import { ROUTES } from '@/router/paths'; @@ -15,6 +13,7 @@ import { ChromeCluster } from '@/views/CityView/chrome/ChromeCluster/ChromeClust import { ProjectSwitcher } from '@/components/sources/ProjectSwitcher/ProjectSwitcher'; import { CopyButton } from '@/components/buttons/CopyButton/CopyButton'; import { ScanMenu } from '@/components/menus/ScanMenu/ScanMenu'; +import { useProject } from '@/state/project/context'; export interface AppHeaderProps { /** Fires when the user clicks the project chip to switch source. */ @@ -25,8 +24,9 @@ export interface AppHeaderProps { } export function CityHeader({ onSwitchSource, onRefresh }: AppHeaderProps = {}) { - const si = SOURCE_INFO.value; - const remoteUrl = (MANIFEST.value as Manifest)?.repo?.remote_url ?? null; + const { source, manifest } = useProject(); + const si = source.info.value; + const remoteUrl = (manifest.current.value as Manifest)?.repo?.remote_url ?? null; // Nothing loaded: the freshness cluster would be reporting on a project that // doesn't exist, and refresh would have nothing to re-open. const hasProject = Boolean(si.src); diff --git a/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx b/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx index 5b0a5095..d39da8e5 100644 --- a/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx +++ b/app/src/views/CityView/chrome/CitySidebarLeft/CitySidebarLeft.tsx @@ -10,15 +10,12 @@ import { SIDEBAR_TAB, SIDEBAR_COLLAPSED } from '@/state/stores/chrome'; import { CHANGED_SETTINGS_COUNT } from '@/state/settings/indicators'; import { SidebarTab, NodeKind } from '@/types'; import type { PickTarget, TreeNode } from '@/types'; -import { SCENE_HANDLE, goToPath, hoverPath, clearHover } from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { PANE_MANIFEST } from '@/state/stores/timeline'; -import { CURRENT_SOURCE } from '@/state/stores/source'; import { ExplorePane } from '@/views/CityView/panes/ExplorePane/ExplorePane'; import { InfoPane } from '@/views/CityView/panes/InfoPane/InfoPane'; import { SearchPane } from '@/views/CityView/panes/SearchPane/SearchPane'; import { ControlsPane } from '@/views/CityView/panes/ControlsPane/ControlsPane'; import { Sidebar, SidebarSide } from '@/components/panes/Sidebar/Sidebar'; +import { useProject } from '@/state/project/context'; // ── Helpers ────────────────────────────────────────────────────────── @@ -81,6 +78,7 @@ function ActivityBar({ activeTab, collapsed, onIconClick }: ActivityBarProps) { // ── Main component ─────────────────────────────────────────────────── export function CitySidebarLeft() { + const { manifest, source, timeline, commands, city } = useProject(); // Both live in the store so the header can send you to a pane; still not // persisted, and still force-closed on every world load. const activeTab = SIDEBAR_TAB; @@ -95,7 +93,7 @@ export function CitySidebarLeft() { // Mirror picker.selection.value → selectedPath. Re-runs on every // SCENE_HANDLE swap and every picker change. useSignalEffect(() => { - const handle = SCENE_HANDLE.value; + const handle = city.value; if (!handle) { selectedPath.value = null; return; @@ -104,7 +102,7 @@ export function CitySidebarLeft() { }); useSignalEffect(() => { - const handle = SCENE_HANDLE.value; + const handle = city.value; if (!handle) { hoveredPath.value = null; return; @@ -115,7 +113,7 @@ export function CitySidebarLeft() { // Closed on every world commit, so a new city opens unobscured. Live reloads // don't write CURRENT_SOURCE, so this can't fight a manual change. useSignalEffect(() => { - if (CURRENT_SOURCE.value) { + if (source.current.value) { activeTab.value = DEFAULT_SIDEBAR_TAB; collapsed.value = true; } @@ -123,7 +121,7 @@ export function CitySidebarLeft() { // Auto-collapse when the manifest has no content (cold-boot empty state). // The activity bar stays visible but the panel is hidden. - const manifestIsEmpty = useComputed(() => !MANIFEST.value); + const manifestIsEmpty = useComputed(() => !manifest.current.value); const onIconClick = (tab: SidebarTab) => { if (!collapsed.value && tab === activeTab.value) { @@ -141,10 +139,10 @@ export function CitySidebarLeft() { // Bound at call time, so they always reach the live scene. Rows hand back a // node; the path-shaped handlers use the scene commands directly below. const onTreeSelect = (node: TreeNode) => { - if (node?.path) goToPath(node.path); + if (node?.path) commands.goToPath(node.path); }; const onTreeHover = (node: TreeNode) => { - if (node?.path) hoverPath(node.path); + if (node?.path) commands.hoverPath(node.path); }; // Effective collapsed: forced when manifest is empty. @@ -163,21 +161,25 @@ export function CitySidebarLeft() {
{tab === SidebarTab.Explore && ( )} {tab === SidebarTab.Search && ( - + )} - {tab === SidebarTab.Info && } + {tab === SidebarTab.Info && } {tab === SidebarTab.Controls && ( )} diff --git a/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx b/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx index a2af9b53..2b837052 100644 --- a/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx +++ b/app/src/views/CityView/chrome/CitySidebarRight/CitySidebarRight.tsx @@ -5,24 +5,10 @@ import './CitySidebarRight.css'; import { useComputed } from '@preact/signals'; import { NodeKind } from '@/types'; +import type { City } from '@/city/types'; +import type { ManifestValue } from '@/state/stores/manifest'; import type { CommitEntry, DirNode, FileNode, Manifest } from '@/types'; -import { - SCENE_HANDLE, - type SceneHandle, - clearSelection, - focusPath, - focusCommit, -} from '@/city/sceneHandle'; -import { MANIFEST } from '@/state/stores/manifest'; -import { - PANE_MANIFEST, - PRESENT_PATHS, - TIMELINE_MODE, - scrubbedDirFor, -} from '@/state/stores/timeline'; -import { SOURCE_INFO, addExclude } from '@/state/stores/source'; import { ROOT_PATH } from '@/constants/manifest'; -import { viewCommitInTimeline } from '@/hooks/useTimelineMode'; import { findNodeByPath, sourceOf } from '@/utils/manifest'; import { FilePreviewPane } from '@/views/CityView/panes/FilePreviewPane/FilePreviewPane'; import type { FilePreviewPaneState } from '@/views/CityView/panes/FilePreviewPane/FilePreviewPane'; @@ -32,6 +18,7 @@ import { StreetPane } from '@/views/CityView/panes/StreetPane/StreetPane'; import type { StreetPaneState } from '@/views/CityView/panes/StreetPane/StreetPane'; import { Sidebar, SidebarSide } from '@/components/panes/Sidebar/Sidebar'; import { SELECTION_PANE_DISMISSED, dismissSelectionPane } from '@/state/stores/chrome'; +import { useProject } from '@/state/project/context'; /** Which pane the right sidebar is showing, from the current picker selection. */ enum SidebarPaneKind { @@ -41,12 +28,14 @@ enum SidebarPaneKind { } // The commit and street panes need things no manifest field holds, so these -// derive them from the scene handle. +// derive them from the city and the manifest it was built from. -function commitStateFor(handle: SceneHandle, commit: CommitEntry): CommitPaneState { - // peek: the calling computed already re-derives on MANIFEST, so a tracked - // read here would only double it. - const m = MANIFEST.peek() as Manifest; +function commitStateFor( + handle: City, + manifest: ManifestValue, + commit: CommitEntry +): CommitPaneState { + const m = manifest as Manifest; return { commit, remoteUrl: m?.repo?.remote_url ?? null, @@ -60,10 +49,11 @@ function commitStateFor(handle: SceneHandle, commit: CommitEntry): CommitPaneSta // ── Main component ─────────────────────────────────────────────────── export function CitySidebarRight() { + const { manifest, source, timeline, timelineMode, commands, city } = useProject(); // Computeds read during render: no effect writing signals, no bridge, and a // live-update poll re-derives every pane on its own. const activeKind = useComputed(() => { - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const sel = city.value?.picker.selection.value ?? null; // Every selection opens the panel: the sidebar is the only place one is // shown. The panes handle the union-city caveat themselves. if (sel?.kind === NodeKind.Commit) return SidebarPaneKind.Commit; @@ -74,8 +64,8 @@ export function CitySidebarRight() { const fileState = useComputed(() => { // History manifest, so the pane follows the scrub: a file absent at this // commit says so here instead of quietly showing HEAD's version. - const m = PANE_MANIFEST.value as Manifest | DirNode | null; - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const m = timeline.paneManifest.value as Manifest | DirNode | null; + const sel = city.value?.picker.selection.value ?? null; if (sel?.kind !== NodeKind.File) return { file: null }; const fresh = findNodeByPath(m, sel.file.path); // Excludes never reach here: they're filtered out of the timeline union too, @@ -85,44 +75,44 @@ export function CitySidebarRight() { file: present ? fresh : sel.file, // The scrub manifest's own source: its paths are what the pane reads by. source: sourceOf(m as Manifest | null), - rootLabel: SOURCE_INFO.value.label, + rootLabel: source.info.value.label, rootPath: (m as Manifest)?.tree?.path ?? ROOT_PATH, remoteUrl: (m as Manifest)?.repo?.remote_url ?? null, - branch: SOURCE_INFO.value.branch, - isAbsent: TIMELINE_MODE.value && !present, + branch: source.info.value.branch, + isAbsent: timeline.mode.value && !present, }; }); const commitState = useComputed(() => { - void MANIFEST.value; // re-derive on live-update rebuilds - const inTimeline = TIMELINE_MODE.value; // re-derive so the button label tracks the mode - const handle = SCENE_HANDLE.value; + void manifest.current.value; // re-derive on live-update rebuilds + const inTimeline = timeline.mode.value; // re-derive so the button label tracks the mode + const handle = city.value; const sel = handle?.picker.selection.value ?? null; return handle && sel?.kind === NodeKind.Commit ? { - ...commitStateFor(handle, sel.commit), - source: sourceOf(MANIFEST.value as Manifest | null), + ...commitStateFor(handle, manifest.current.value, sel.commit), + source: sourceOf(manifest.current.value as Manifest | null), inTimeline, } : { commit: null }; }); const streetState = useComputed(() => { - const m = MANIFEST.value as Manifest | DirNode | null; - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const m = manifest.current.value as Manifest | DirNode | null; + const sel = city.value?.picker.selection.value ?? null; if (sel?.kind !== NodeKind.Directory) return { directory: null }; // In Timeline the rollups are re-added at the settled commit from the same // per-blob numbers the buildings use, so the pane cannot disagree with them. - const fresh = TIMELINE_MODE.value - ? scrubbedDirFor(sel.dir.path) + const fresh = timeline.mode.value + ? timeline.scrubbedDirFor(sel.dir.path) : findNodeByPath(m, sel.dir.path); return { directory: fresh?.type === NodeKind.Directory ? fresh : sel.dir, - rootLabel: SOURCE_INFO.value.label, + rootLabel: source.info.value.label, rootPath: (m as Manifest)?.tree?.path ?? ROOT_PATH, remoteUrl: (m as Manifest)?.repo?.remote_url ?? null, - branch: SOURCE_INFO.value.branch, + branch: source.info.value.branch, // The same set the sidebar tree filters on, so a road the tree has dropped // can't still read as live here. - isAbsent: TIMELINE_MODE.value && !PRESENT_PATHS.value.has(sel.dir.path), + isAbsent: timeline.mode.value && !timeline.presentPaths.value.has(sel.dir.path), }; }); @@ -134,13 +124,13 @@ export function CitySidebarRight() { // Clearing the drawers out of the way on a phone is the focus command's job // (stores/scene), so every focus button in the app behaves the same. - const onFileFocus = (file: FileNode) => focusPath(file.path); - const onCommitFocus = (commit: CommitEntry) => focusCommit(commit.sha); - const onStreetFocus = (dir: DirNode) => focusPath(dir.path); + const onFileFocus = (file: FileNode) => commands.focusPath(file.path); + const onCommitFocus = (commit: CommitEntry) => commands.focusCommit(commit.sha); + const onStreetFocus = (dir: DirNode) => commands.focusPath(dir.path); const onExcludeNode = (path: string) => { - addExclude(path); - clearSelection(); // the node is about to vanish from the re-fetched manifest + source.addExclude(path); + commands.clearSelection(); // the node is about to vanish from the re-fetched manifest }; const kind = activeKind.value; @@ -167,7 +157,7 @@ export function CitySidebarRight() { state={commitState} onClose={dismiss} onFocus={onCommitFocus} - onViewInTimeline={(commit) => void viewCommitInTimeline(commit.sha)} + onViewInTimeline={(commit) => void timelineMode.viewCommit(commit.sha)} /> )} {kind === SidebarPaneKind.Street && ( diff --git a/app/src/views/CityView/chrome/CityStage/CityStage.tsx b/app/src/views/CityView/chrome/CityStage/CityStage.tsx index c97c6a80..d47d1dad 100644 --- a/app/src/views/CityView/chrome/CityStage/CityStage.tsx +++ b/app/src/views/CityView/chrome/CityStage/CityStage.tsx @@ -3,17 +3,19 @@ import './CityStage.css'; import { City } from '@/city/City'; -import { OPENED_PROJECT } from '@/city/openedProject'; +import { cityPropsFor } from '@/city/forProject'; +import { useProject } from '@/state/project/context'; import { TimelineScrubber } from '@/components/timeline/TimelineScrubber/TimelineScrubber'; import { TimelineToggle } from '@/components/timeline/TimelineToggle/TimelineToggle'; import { SelectionChip } from '@/views/CityView/chrome/CityStage/SelectionChip/SelectionChip'; export function CityStage() { + const session = useProject(); return (
{/* Opaque: nothing sits behind this canvas, so a resize gap would flash the page through it. */} - +
diff --git a/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx b/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx index eaaaa7dc..15677784 100644 --- a/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx +++ b/app/src/views/CityView/chrome/CityStage/SelectionChip/SelectionChip.tsx @@ -6,7 +6,7 @@ import './SelectionChip.css'; import { PanelRightOpen, X } from 'lucide-preact'; import { useComputed } from '@preact/signals'; import { NodeKind } from '@/types'; -import { SCENE_HANDLE, clearSelection } from '@/city/sceneHandle'; +import { useProject } from '@/state/project/context'; import { SELECTION_PANE_DISMISSED, openSelectionPane } from '@/state/stores/chrome'; import { KindBadge } from '@/components/nodes/KindBadge/KindBadge'; @@ -20,8 +20,9 @@ interface ChipSelection { /** The selected node as the chip shows it, or null when nothing is selected. */ function useChipSelection() { + const { city } = useProject(); return useComputed(() => { - const sel = SCENE_HANDLE.value?.picker.selection.value ?? null; + const sel = city.value?.picker.selection.value ?? null; if (sel?.kind === NodeKind.File) return { label: sel.file.name, kind: NodeKind.File, extension: sel.file.extension }; if (sel?.kind === NodeKind.Directory) return { label: sel.dir.name, kind: NodeKind.Directory }; @@ -32,6 +33,7 @@ function useChipSelection() { } export function SelectionChip() { + const { commands } = useProject(); const selection = useChipSelection(); // Only in the state the pane leaves behind: something selected, details put // away. With the pane open it would name what the pane already titles. @@ -47,7 +49,7 @@ export function SelectionChip() { class="selection-chip-clear" title="Clear selection" aria-label={`Clear selection: ${label}`} - onClick={clearSelection} + onClick={commands.clearSelection} >