timeline && exitTimelineMode()}
+ onClick={() => timeline && timelineMode.exit()}
>
Live
@@ -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/constants/progress.ts b/app/src/constants/progress.ts
index faf97a897..00505eef7 100644
--- a/app/src/constants/progress.ts
+++ b/app/src/constants/progress.ts
@@ -2,9 +2,9 @@
// the overlay advances through, and the sub-stages that run inside its last row
// once the stream has handed over to the build.
-import { ScanPhase } from '@/api/manifest';
+import { ScanPhase } from '@/city/session/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/useDocumentTitle.ts b/app/src/hooks/useDocumentTitle.ts
index f8ab608ac..daf168481 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 { useCity } from '@/city/CityProvider';
export function useDocumentTitle(): void {
+ const { manifest } = useCity();
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
deleted file mode 100644
index 463304524..000000000
--- a/app/src/hooks/useHomeBackdrop.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-// hooks/useHomeBackdrop.ts — paints a city behind the landing for as long as
-// HomeView is mounted: the project you were last in, from whatever the server
-// 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.
-
-import { useEffect } from 'preact/hooks';
-import { effect } 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 { MANIFEST } from '@/state/stores/manifest';
-import { RECENTS, CURRENT_SOURCE, BACKDROP_CITY, BackdropKind } from '@/state/stores/source';
-import { identityBranch, resolveBranch, sameSourceIdentity } from '@/utils/sources';
-import type { Manifest } from '@/types';
-
-interface Candidate {
- src: string;
- branch?: string;
- kind: BackdropKind;
- fetch: (signal: AbortSignal) => Promise;
-}
-
-/** Stream the featured repo. Only the complete manifest: a skeleton's
- * placeholder heights would show the city snapping into shape. */
-async function streamFeatured(src: string, signal: AbortSignal): Promise {
- let complete: Manifest | null = null;
- try {
- for await (const event of streamManifest(manifestUrlFor({ src }), { signal })) {
- if (event.phase === ScanPhase.Error) return null;
- if (event.phase === ScanPhase.CompleteManifest) complete = event.manifest;
- }
- } catch (_) {
- return null;
- }
- return complete;
-}
-
-// Building a city holds the main thread for seconds on a big repo, and behind
-// the landing that is a frozen page for a city nobody came to look at.
-const BACKDROP_MAX_FILES = 5000;
-
-/** Whether this manifest is small enough to build without freezing the page. */
-function fitsBehindTheLanding(manifest: Manifest): boolean {
- const files = manifest.tree?.descendants_file_count;
- return files === undefined || files <= BACKDROP_MAX_FILES;
-}
-
-/** Who gets to be the backdrop, best first. */
-function candidates(featuredRepo: string | undefined): 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();
- if (current && loaded) {
- out.push({
- src: current.src,
- branch: current.branch,
- kind: BackdropKind.Recent,
- fetch: () => Promise.resolve(loaded as Manifest),
- });
- }
- const recent = RECENTS.peek()[0];
- if (recent && !sameSourceIdentity(recent, current ?? { src: '' })) {
- out.push({
- src: recent.src,
- branch: recent.branch,
- kind: BackdropKind.Recent,
- fetch: (signal) => fetchCachedManifest(recent.src, recent.branch, signal),
- });
- }
- // Skipped when it IS the recent one: it would only paint the same city again.
- if (featuredRepo && featuredRepo !== recent?.src && featuredRepo !== current?.src) {
- out.push({
- src: featuredRepo,
- kind: BackdropKind.Featured,
- fetch: (signal) => streamFeatured(featuredRepo, signal),
- });
- }
- return out;
-}
-
-export function useHomeBackdrop(): void {
- useEffect(() => {
- const controller = new AbortController();
- // One go each, so a re-run cannot re-fetch a failure: this is what lets
- // the effect give featured its turn when the config lands late.
- const tried = new Set();
- let inFlight = false;
-
- async function tryNext(handle: SceneHandle, featuredRepo?: string): Promise {
- const signal = controller.signal;
- const next = candidates(featuredRepo).find((c) => !tried.has(`${c.kind}:${c.src}`));
- if (!next) return;
- tried.add(`${next.kind}:${next.src}`);
- inFlight = true;
- try {
- const manifest = await next.fetch(signal);
- if (signal.aborted) return;
- // 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);
- return;
- }
- await handle.applyManifest(manifest);
- if (signal.aborted) return;
- BACKDROP_CITY.value = {
- 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;
- }
- }
-
- 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);
- });
-
- return () => {
- stop();
- controller.abort();
- BACKDROP_CITY.value = null;
- };
- }, []);
-}
diff --git a/app/src/hooks/useManifestSource.ts b/app/src/hooks/useManifestSource.ts
deleted file mode 100644
index 778de7690..000000000
--- a/app/src/hooks/useManifestSource.ts
+++ /dev/null
@@ -1,440 +0,0 @@
-// 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.
-
-import { useEffect } from 'preact/hooks';
-import { computed, effect } from '@preact/signals';
-
-import {
- manifestUrlFor,
- signatureUrlFor,
- streamManifest,
- ScanPhase,
- ScanError,
-} 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,
- activeExcludePathsFor,
- ACTIVE_EXCLUDES,
-} 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,
-} from '@/state/stores/progress';
-import { TIMELINE_MODE, resetTimelineMode } from '@/state/stores/timeline';
-import {
- srcKind,
- SourceKind,
- identityBranch,
- sourceKey,
- sameSourceIdentity,
- sourceIdentity,
-} from '@/utils/sources';
-import { readUrlView, type UrlView } from '@/router/viewParams';
-import { ROUTE_PARAMS, ROUTE_PATH } from '@/router/location';
-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 },
- onManifest: (
- manifest: Manifest,
- phase: ScanPhase.PartialManifest | ScanPhase.CompleteManifest
- ) => Promise | void,
- signal?: AbortSignal
-): Promise {
- let lastManifest: Manifest | null = null;
- // Only partials are applied in here, so the complete event carries the last
- // applied `pending` forward rather than claiming its own as applied.
- let appliedPending: Manifest['pending'] | undefined;
-
- for await (const event of streamManifest(url, { signal })) {
- if (event.phase === ScanPhase.Error) throw new ScanError(event.error, event.code);
-
- 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;
- }
-
- if (event.phase === ScanPhase.CloneProgress || event.phase === ScanPhase.ScanProgress) {
- SCAN_PROGRESS.value = {
- ...meta,
- phase: event.phase,
- percent: event.phase === ScanPhase.CloneProgress ? event.percent : undefined,
- stage: event.phase === ScanPhase.CloneProgress ? event.stage : undefined,
- mbOnDisk: event.phase === ScanPhase.CloneProgress ? event.mb_on_disk : undefined,
- objects: event.phase === ScanPhase.CloneProgress ? event.objects : undefined,
- objectsTotal: event.phase === ScanPhase.CloneProgress ? event.objects_total : undefined,
- mib: event.phase === ScanPhase.CloneProgress ? event.mib : undefined,
- filesScanned: event.phase === ScanPhase.ScanProgress ? event.files_scanned : undefined,
- };
- continue;
- }
-
- // 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;
- 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 };
- }
-
- 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;
-}
-
-// ── 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();
- // 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.
- const opened = CURRENT_SOURCE.peek();
- if (!opened || !sameSourceIdentity(opened, { src: payload.src, branch })) {
- setManifest(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 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;
- }
- }
-}
-
-/** 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();
-}
-
-/** 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;
- }
- void loadSource({ src: cur.src, branch: cur.branch, skipCache: skipCache || undefined });
-}
-
-// ── 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;
-
-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;
-}
-
-/** 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;
-
- 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);
- }
- } 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 (LIVE_UPDATES_ACTIVE.value) start();
- else stop();
- });
-
- // 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;
- });
- });
-
- 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 {
- return effect(() => {
- if (!URL_SOURCE.value) return;
- // 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.
- const cur = CURRENT_SOURCE.peek();
- if (cur && boot.src && sameSourceIdentity(cur, { src: boot.src, branch: boot.branch })) {
- return;
- }
- void bootLoad(boot);
- });
- });
-}
-
-// ── 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 {
- 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();
- (async () => {
- // Independent boot reads, so they go out together rather than making the
- // landing wait for two round trips in series.
- const [serverConfig, discover] = await Promise.all([getServerConfig(), getDiscover()]);
- if (cancelled) return;
- SERVER_CONFIG.value = serverConfig;
- DISCOVER.value = discover;
-
- // 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();
- })();
- return () => {
- cancelled = true;
- disposeRouteLoad();
- disposeLiveUpdates?.();
- };
- }, []);
-}
diff --git a/app/src/hooks/useTimelineMode.ts b/app/src/hooks/useTimelineMode.ts
deleted file mode 100644
index 8bed3bb6a..000000000
--- a/app/src/hooks/useTimelineMode.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-// 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.
-
-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 {
- BuildStage,
- PACK_STAGES,
- LoadingStep,
- TIMELINE_LOADING_STEPS,
- stepForTimelineStage,
- transferTail,
-} 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 { Manifest, TimelineBundle, TimelineProgress } from '@/types';
-
-/** How far the current stage has got. Written beside its own step row, and
- * standalone beside the freshness dot, so it names its own units. */
-function timelineStageTail(p: TimelineProgress): string | null {
- if (p.stage === TimelineStage.Fetch) return transferTail(p);
- if (p.stage === TimelineStage.History) {
- return p.commits !== undefined ? `${p.commits.toLocaleString()} commits` : null;
- }
- if (p.blobsDone !== undefined && p.blobsTotal !== undefined) {
- return `${p.blobsDone}/${p.blobsTotal} files`;
- }
- return 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;
-
- // 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();
- });
- }
-
- // 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;
-
- // 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.
-
- if (!assemblyOpen) {
- assemblyOpen = true;
- beginBuild(buildPlan(PACK_STAGES));
- }
- 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);
- };
-
- 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) {
- // Hold the overlay through the union city's first painted frame, then reveal.
- requestAnimationFrame(() => {
- 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 */
- }
- }
- 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;
- }
- 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 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
- }
- 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);
-}
-
-// Scene-free: the city-layer effect (city/index.ts) reacts to TIMELINE_MODE and does the scene teardown.
-export function teardownTimelineMode(): void {
- resetTimelineMode();
-}
-
-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);
- });
- });
- void loadSource({ src: cur.src, branch: cur.branch });
-}
diff --git a/app/src/main.tsx b/app/src/main.tsx
index 09739652b..377963e18 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 000000000..f0cb04ec6
--- /dev/null
+++ b/app/src/router/urlBinding.ts
@@ -0,0 +1,93 @@
+// 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 describes, which is one of them: a column view attaches it to none.
+
+import { URL_PARAMS } from '@/constants/urlParams';
+import { VIEW_PARAMS } from '@/router/params';
+import { ROUTES } from '@/router/paths';
+import { navigate, hrefFor, ROUTE_SEARCH, ROUTE_PATH, ROUTE_PARAMS } from '@/router/location';
+import { computed, effect } from '@preact/signals';
+import { attachViewUrlReactions } from '@/router/viewBinding';
+import { readUrlView } from '@/router/viewParams';
+import { sourceIdentity } from '@/utils/sources';
+import type { CitySession } from '@/city/session/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. Exported for the view-params tests beside it. */
+export function attachSourceReflection(session: CitySession): () => 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 });
+ });
+}
+
+/** What the address bar 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) : '';
+});
+
+/** Follow the URL into `session`: the boot read and every later Back/Forward.
+ * A bare ?src is complete; the server resolves the default branch. */
+export function attachRouteLoad(session: CitySession): () => 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 asked = 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 city again is the branch a commit put in the URL.
+ if (!arriving && session.source.isOpen(asked.src, asked.branch)) return;
+ void session.load.boot(asked);
+ });
+ });
+}
+
+/** Point the address bar at `session`, both ways. Returns a dispose. */
+export function attachUrlBinding(session: CitySession): () => void {
+ const stops = [
+ attachSourceReflection(session),
+ attachRouteLoad(session),
+ attachViewUrlReactions(session),
+ ];
+ return () => stops.forEach((stop) => stop());
+}
diff --git a/app/src/router/viewBinding.ts b/app/src/router/viewBinding.ts
index 136b6c1ba..3ab996c9b 100644
--- a/app/src/router/viewBinding.ts
+++ b/app/src/router/viewBinding.ts
@@ -9,129 +9,126 @@ 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 { BUILT_MANIFEST } from '@/state/stores/progress';
-import { goToPath, goToCommit, clearSelection } from '@/city/sceneHandle';
-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 type { CitySession } from '@/city/session/session';
+import { FocusMode } from '@/city/scene/render/cameraRig';
import { NodeKind } from '@/types';
-import type { PickerSelectionKey } from '@/types';
+import type { PickerSelectionKey } from '@/city/scene/types/picker';
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 = BUILT_MANIFEST.value !== null;
- // 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 (mode, scrubbed commit, selection) to the URL, both
+ * ways. Attached for the session the address bar describes; others are not. */
+export function attachViewUrlReactions(session: CitySession): () => void {
+ const { source, progress, timeline, commands, scene } = 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(scene.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(scene.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/router/viewParams.ts b/app/src/router/viewParams.ts
index 035f3c271..9f4678bca 100644
--- a/app/src/router/viewParams.ts
+++ b/app/src/router/viewParams.ts
@@ -7,7 +7,7 @@ import { URL_PARAMS } from '@/constants/urlParams';
import { VIEW_PARAMS, TIMELINE_MODE_PARAM, SELECTION_KIND_PARAMS } from '@/router/params';
import { identityBranch } from '@/utils/sources';
import { NodeKind } from '@/types';
-import type { PickerSelectionKey } from '@/types';
+import type { PickerSelectionKey } from '@/city/scene/types/picker';
/** The view a URL is asking for. */
export interface UrlView {
diff --git a/app/src/state/cityChrome.ts b/app/src/state/cityChrome.ts
new file mode 100644
index 000000000..cab08dbae
--- /dev/null
+++ b/app/src/state/cityChrome.ts
@@ -0,0 +1,36 @@
+// state/city/chrome.ts — codecity's answer to what a city asks of the app it is
+// mounted in: which pane opens, which drawer closes, and whether a modal has
+// the keyboard. The city layer knows only the interface (see city/types).
+
+import { IS_PHONE } from '@/state/stores/viewport';
+import {
+ OVERLAY_OPEN,
+ SIDEBAR_COLLAPSED,
+ dismissSelectionPane,
+ openSelectionPane,
+} from '@/state/stores/chrome';
+import type { CityChrome } from '@/city/scene/types';
+
+export class SessionChrome implements CityChrome {
+ keyboardBusy = (): boolean => OVERLAY_OPEN.value;
+
+ /** You asked for the node by name, so its details are the answer, and only
+ * the phone drawer has to move. */
+ showDetails = (): void => {
+ openSelectionPane();
+ this.collapseDrawerOnPhone();
+ };
+
+ /** The other half of that choice: you asked to see the city, so what is in
+ * the way goes, and the chip stands in for the details. */
+ revealCity = (): void => {
+ dismissSelectionPane();
+ this.collapseDrawerOnPhone();
+ };
+
+ /** Phone: the left drawer covers the city, so a camera move behind it is one
+ * you can't see. It is the whole screen there and a column everywhere else. */
+ private collapseDrawerOnPhone(): void {
+ if (IS_PHONE.peek()) SIDEBAR_COLLAPSED.value = true;
+ }
+}
diff --git a/app/src/state/settings/indicators.ts b/app/src/state/settings/indicators.ts
index 94c2f8738..4a28a8f60 100644
--- a/app/src/state/settings/indicators.ts
+++ b/app/src/state/settings/indicators.ts
@@ -5,7 +5,7 @@ import { computed } from '@preact/signals';
import { forEachSettingStore, getFieldKeys } from '@/state/settings/schema';
import { getDefault } from '@/state/persist';
import { deepEqual } from '@/utils/deep';
-import { LIVE_UPDATES } from '@/state/settings/fields/updates';
+import { LIVE_UPDATES } from '@/city/session/settings/updates';
import { ACCENT_THEME, SURFACE_THEME } from '@/state/settings/fields/theme';
import { SYNTAX_THEME } from '@/state/settings/fields/syntaxTheme';
diff --git a/app/src/state/settings/schema.ts b/app/src/state/settings/schema.ts
index 7bdbc26f2..5323de8ae 100644
--- a/app/src/state/settings/schema.ts
+++ b/app/src/state/settings/schema.ts
@@ -90,6 +90,11 @@ export function forEachSettingStore(cb: (store: SettingStore) => void): void {
for (const s of _SETTING_STORES) cb(s as SettingStore);
}
+/** What a store's fields are, for anything folding its values by route. */
+export function fieldsOf(store: SettingStore): FieldMap {
+ return _FIELDS.get(store) ?? {};
+}
+
const _AUTOSAVE_STORES = new WeakSet();
/** Write-through: widgets apply on change instead of staging drafts. The
@@ -202,15 +207,3 @@ export function getFieldKeys(store: object): string[] {
/** A string that changes iff a field with this route changes, so the wrapping
* computed notifies for one route only. Call inside a computed(). */
-export function routeSignature(route: ChangeRoute): string {
- let sig = '';
- for (const [store, fields] of _FIELDS) {
- const v = (store as SettingStore).value as Record;
- for (const key in fields) {
- if (fields[key].route === route) {
- sig += `${key}=${JSON.stringify(v[key])};`;
- }
- }
- }
- return sig;
-}
diff --git a/app/src/state/settings/transfer.ts b/app/src/state/settings/transfer.ts
index 7b063e399..9a347b7e5 100644
--- a/app/src/state/settings/transfer.ts
+++ b/app/src/state/settings/transfer.ts
@@ -5,7 +5,8 @@
import { batch } from '@preact/signals';
import { getStoreName, getDefault } from '@/state/persist';
-import { CURRENT_SOURCE, activeExcludePathsFor, setExcludesFor } from '@/state/stores/source';
+import { type SourceStore } from '@/city/session/stores/source';
+import { activeExcludePathsFor, setExcludesFor } from '@/city/session/stores/excludes';
import { deepEqual } from '@/utils/deep';
import { coerceFieldValue, type SettingStore } from './schema';
import { dropDrafts } from './drafts';
@@ -134,35 +135,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 +241,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
deleted file mode 100644
index 726415c0c..000000000
--- a/app/src/state/stores/manifest.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-// 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.
-
-import { 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);
-
-/** 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;
-}
diff --git a/app/src/state/stores/progress.ts b/app/src/state/stores/progress.ts
deleted file mode 100644
index 2191e137f..000000000
--- a/app/src/state/stores/progress.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-// 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.
-
-import { signal, effect } from '@preact/signals';
-import { SourceKind } from '@/utils/sources';
-import { ScanPhase, CloneStage } from '@/api/manifest';
-import type { Manifest } from '@/types';
-import {
- BuildStage,
- buildStageTail,
- type BuildProgress,
- LoadingStep,
- LOADING_STEPS,
- firstStepFor,
- stepForPhase,
- transferTail,
-} from '@/constants/progress';
-import { MANIFEST, type ManifestValue } from './manifest';
-import type { LoadingOverlayShowOpts, LoadingOverlayState } from '@/types/ui';
-
-// ── What the server is doing ─────────────────────────────────────────
-
-export interface ScanProgress {
- /** Kind of source being loaded (drives the overlay's initial step). */
- kind: SourceKind;
- branch?: string;
- /** Latest stream phase, or null when the load just started and no stream
- * event has arrived yet (overlay shows the kind-based initial step). */
- phase: ScanPhase | null;
- /** Cloning percent (0-100) when phase === Cloning. */
- percent?: number;
- /** Cloning stage (e.g. Receiving, Updating) when present. */
- stage?: CloneStage;
- /** Working-tree size on disk (MB) during the silent promisor blob fetch —
- * a clone-progress heartbeat with no stage/percent. */
- mbOnDisk?: number;
- /** Git's own counts for the clone, where its line carried them. */
- objects?: number;
- objectsTotal?: number;
- mib?: number;
- /** Files scanned so far when phase === Scanning. */
- filesScanned?: number;
- /** `pending` of the manifest most recently APPLIED this load. Absent until
- * its first manifest event, so a previous repo's can't leak in. */
- 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
- * screen with its deferred pass (trees and friends) still in flight. */
-export enum RebuildStatus {
- /** Nothing has been built yet. Distinct from Idle so "a build finished" is
- * answerable: consumers that wait for Idle used to pass at boot. */
- Pending = 'pending',
- Idle = 'idle',
- Rebuilding = 'rebuilding',
- Decorating = 'decorating',
- 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);
-
-/** 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: Timeline's 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();
-}
-
-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 };
-}
-
-// ── 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 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 },
- };
-}
-
-// ── The Live driver ──────────────────────────────────────────────────
-
-export function attachOverlayDriver(): () => void {
- const stops = [attachScanReaction(), 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));
- });
-}
-
-function attachScanReaction(): () => 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);
- };
- 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.
- const status = REBUILD_STATUS.value;
- const building = status === RebuildStatus.Rebuilding || status === RebuildStatus.Decorating;
- const hide = () => {
- if (overlayUp) hideLoadingOverlay();
- overlayUp = false;
- };
- if (!p) {
- if (building) {
- // Stream done, city still assembling — keep the overlay on "Building".
- advance(LoadingStep.Building);
- return;
- }
- hide();
- return;
- }
- // 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).
- showLoadingOverlay({ kind: p.kind, branch: p.branch });
- overlayUp = true;
- reached = LOADING_STEPS.indexOf(firstStepFor(LOADING_STEPS, p.kind));
- }
- advance(stepForPhase(p.phase, p.kind));
- 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.
- });
-}
diff --git a/app/src/state/stores/recents.ts b/app/src/state/stores/recents.ts
new file mode 100644
index 000000000..14923af68
--- /dev/null
+++ b/app/src/state/stores/recents.ts
@@ -0,0 +1,56 @@
+// state/stores/recents.ts — the repos you have opened, most recent first.
+// App-wide and persisted: it outlives any one city, and two cities open at
+// once both feed it.
+
+import { persistedSignal } from '@/state/persist';
+import { PERSISTED_KEYS } from '@/constants/storage';
+import { MAX_RECENT_SOURCES } from '@/constants/ui';
+import { sameSourceIdentity } from '@/utils/sources';
+import { effect, untracked } from '@preact/signals';
+import type { CitySession } from '@/city/session/session';
+
+export interface RecentSource {
+ src: string; // exactly what was typed / passed; goes into ?src=
+ branch?: string; // identity; identityBranch strips it for a local source
+ // Display only. The label comes from the git remote, so every worktree of one
+ // repo shares it and this is all that tells those rows apart.
+ checkout?: string;
+ label: string; // derived at save time: basename(src) or "owner/repo"
+ lastOpenedAt: number; // ms since epoch, for MRU sort
+}
+
+/** Persisted list of recently-opened sources. Hydrates at module load. */
+export const RECENTS = persistedSignal(PERSISTED_KEYS.RECENTS, []);
+
+/** Push (or update) an entry, most-recent first. Dedupes by source identity, so
+ * a local path is one row regardless of checkout. Capped, oldest dropped. */
+export function pushRecent(entry: Omit): void {
+ const now = Date.now();
+ const filtered = RECENTS.value.filter((r) => !sameSourceIdentity(r, entry));
+ filtered.unshift({ ...entry, lastOpenedAt: now });
+ RECENTS.value = filtered.slice(0, MAX_RECENT_SOURCES);
+}
+
+/** 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 }));
+}
+
+/** Record every repo `session` opens. Attached to the city you opened and to no
+ * other: the landing's backdrop shows you a repo you never asked for. */
+export function attachRecents(session: CitySession): () => void {
+ return effect(() => {
+ const cur = session.source.current.value;
+ const info = session.source.info.value;
+ if (!cur || !info.label) return;
+ untracked(() =>
+ pushRecent({
+ src: cur.src,
+ label: info.label,
+ branch: cur.branch,
+ // The checkout the manifest reports, which a worktree row is told apart by.
+ checkout: info.branch,
+ })
+ );
+ });
+}
diff --git a/app/src/state/stores/serverData.ts b/app/src/state/stores/serverData.ts
index f021d6aa8..32652a8d9 100644
--- a/app/src/state/stores/serverData.ts
+++ b/app/src/state/stores/serverData.ts
@@ -5,6 +5,9 @@
import { signal } from '@preact/signals';
import { DEFAULT_SERVER_CONFIG, type ServerConfig } from '@/api/config';
import type { DiscoverEntry } from '@/api/discover';
+import { useEffect } from 'preact/hooks';
+import { getServerConfig } from '@/api/config';
+import { getDiscover } from '@/api/discover';
export type { ServerConfig, DiscoverEntry };
export { DEFAULT_SERVER_CONFIG };
@@ -14,3 +17,19 @@ export const SERVER_CONFIG = signal(DEFAULT_SERVER_CONFIG);
// Empty until the fetch lands, and forever if Discover is off. The tab keys its
// visibility off that, so there is no "loaded yet?" flag to keep in step.
export const DISCOVER = signal([]);
+
+/** The two boot reads the whole app shares, together rather than in series. */
+export function useServerData(): void {
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ const [config, discover] = await Promise.all([getServerConfig(), getDiscover()]);
+ if (cancelled) return;
+ SERVER_CONFIG.value = config;
+ DISCOVER.value = discover;
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+}
diff --git a/app/src/state/stores/source.ts b/app/src/state/stores/source.ts
deleted file mode 100644
index 69d1ea198..000000000
--- a/app/src/state/stores/source.ts
+++ /dev/null
@@ -1,253 +0,0 @@
-// 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.
-
-import { signal, computed, effect, type ReadonlySignal } 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,
- resolveBranch,
- identityBranch,
- sourceKey,
- sameSourceIdentity,
-} from '@/utils/sources';
-import type { Manifest, SourceError } from '@/types';
-
-// ── Currently-loaded 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;
-});
-
-/** 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 });
-}
-
-// 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 &&
- !sameSourceIdentity({ src: had, branch: params.get(URL_PARAMS.BRANCH) ?? undefined }, cur)
- ) {
- 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;
- /** Branch name when the loaded source is a git URL with a known branch. */
- branch: string | undefined;
- /** Original git URL when the source is a hosted git repo. */
- sourceUrl: string | undefined;
- /** Raw source as entered: the git URL for a remote, the path for a local. */
- 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 const SOURCE_INFO = computed(() => {
- const cur = CURRENT_SOURCE.value;
- const m = MANIFEST.value;
- if (!cur || !m) {
- return { label: '', branch: undefined, sourceUrl: undefined, src: undefined };
- }
- 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,
- };
-});
-
-// ── Recently-opened sources (persisted) ──────────────────────────────
-
-export interface RecentSource {
- src: string; // exactly what was typed / passed; goes into ?src=
- branch?: string; // identity; identityBranch strips it for a local source
- // Display only. The label comes from the git remote, so every worktree of one
- // repo shares it and this is all that tells those rows apart.
- checkout?: string;
- label: string; // derived at save time: basename(src) or "owner/repo"
- lastOpenedAt: number; // ms since epoch, for MRU sort
-}
-
-/** Persisted list of recently-opened sources. Hydrates at module load. */
-export const RECENTS = persistedSignal(PERSISTED_KEYS.RECENTS, []);
-
-/** Push (or update) an entry, most-recent first. Dedupes by source identity, so
- * a local path is one row regardless of checkout. Capped, oldest dropped. */
-export function pushRecent(entry: Omit): void {
- const now = Date.now();
- const filtered = RECENTS.value.filter((r) => !sameSourceIdentity(r, entry));
- filtered.unshift({ ...entry, lastOpenedAt: now });
- 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 }));
-}
-
-// ── The city behind the landing ───────────────────────────────────────
-
-/** Which repo the backdrop came from. */
-export enum BackdropKind {
- /** The most recent project, from whatever the server had cached for it. */
- Recent = 'recent',
- /** The server's featured repo. */
- Featured = 'featured',
-}
-
-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. */
- branch?: string;
- kind: BackdropKind;
-}
-
-/** Written only once the backdrop has actually painted, so nothing names a repo
- * 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 ─────────────────────────────────
-
-/** repo key -> sorted, de-duped rel-paths. Whole-object persistence: the keys
- * are runtime repo hashes, so diff-vs-default would drop every write. */
-export const EXCLUDES = persistedSignal>(
- PERSISTED_KEYS.EXCLUDES,
- {},
- { whole: true }
-);
-
-/** Repo-scoped key: src only (branch ignored) so excludes hold across branches. */
-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)] ?? [];
-}
-
-/** 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 sorted = [...new Set(next)].sort();
- const map = { ...EXCLUDES.peek() };
- if (sorted.length === 0) delete map[key];
- else map[key] = 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
deleted file mode 100644
index efd1d41ec..000000000
--- a/app/src/state/stores/timeline.ts
+++ /dev/null
@@ -1,385 +0,0 @@
-// 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
-// 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 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 {
- buildPathTimelines,
- ruinStateAt,
- PathState,
- blobShaAt,
- entryAt,
- statsAtDeletion,
- lastModifiedIndexAt,
-} 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(
- node: TreeNode,
- timelines: Map,
- pos: number,
- out: Set
-): boolean {
- if (node.type === NodeKind.File) {
- const pt = node.path != null ? timelines.get(node.path) : undefined;
- const present = pt ? ruinStateAt(pt, pos) === PathState.Present : false;
- if (present && node.path != null) out.add(node.path);
- return present;
- }
- let anyPresent = false;
- for (const child of node.children ?? []) {
- if (_collect(child, timelines, pos, out)) anyPresent = true;
- }
- if (anyPresent && node.path != null) out.add(node.path);
- return anyPresent;
-}
-
-/** A file's measures at the scrub position, or at its deletion if already gone. */
-export interface ScrubbedFileStats {
- // Null when the blob was never fetched: unknown at this commit, not zero.
- lines: number | null;
- bytes: number | null;
- /** True when these are the values the file had when it was deleted. */
- 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 {
- if (!present.has(node.path ?? '')) return null;
- if (node.type === NodeKind.File) return node;
- const children: TreeNode[] = [];
- for (const child of node.children ?? []) {
- const kept = _filterPresent(child, present);
- if (kept) children.push(kept);
- }
- 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
-// subdirectory is absorbed whole, and the extensionless bucket sorts last.
-interface Rollup {
- count: number;
- fileCount: number;
- dirCount: number;
- size: number;
- createdMin: string | null;
- modifiedMax: string | null;
- ext: Map;
-}
-
-function _emptyRollup(): Rollup {
- return {
- count: 0,
- fileCount: 0,
- dirCount: 0,
- size: 0,
- createdMin: null,
- modifiedMax: null,
- ext: new Map(),
- };
-}
-
-// Empty stands for "no date", so it loses to any real one rather than sorting
-// before every ISO string.
-function _minIso(a: string | null, b: string | null | undefined): string | null {
- if (!b) return a;
- if (!a) return b;
- return b < a ? b : a;
-}
-
-function _maxIso(a: string | null, b: string | null | undefined): string | null {
- if (!b) return a;
- if (!a) return b;
- return b > a ? b : a;
-}
-
-function _addExt(roll: Rollup, ext: string | null, count: number, size: number): void {
- const bucket = roll.ext.get(ext);
- if (bucket) {
- bucket.count += count;
- bucket.size += size;
- } else {
- roll.ext.set(ext, { count, size });
- }
-}
-
-function _extBreakdown(roll: Rollup): DirNode['descendants_ext_breakdown'] {
- return [...roll.ext.entries()]
- .map(([ext, { count, size }]) => ({ ext, count, size }))
- .sort(
- (a, b) =>
- b.count - a.count ||
- Number(a.ext === null) - Number(b.ext === null) ||
- (a.ext ?? '').localeCompare(b.ext ?? '')
- );
-}
-
-/** The commit date of the last change to `path` at or before `pos`. The union's
- * own `modified` is the last change EVER, which may be in the scrub's future. */
-function _modifiedAt(pt: PathTimeline, pos: number, commitDates: (string | undefined)[]): string {
- const idx = lastModifiedIndexAt(pt, pos);
- return commitDates[idx] ?? '';
-}
-
-// Post-order, so a directory absorbs children that already know their own
-// totals. Returns null for anything absent at `pos`, which prunes emptied dirs.
-function _rebuildAt(
- node: TreeNode,
- pos: number,
- timelines: Map,
- commitDates: (string | undefined)[],
- into: Rollup
-): TreeNode | null {
- if (node.type === NodeKind.File) {
- const pt = node.path != null ? timelines.get(node.path) : undefined;
- if (!pt || ruinStateAt(pt, pos) !== PathState.Present) return null;
- const entry = entryAt(pt, pos);
- const size = entry?.bytes ?? 0;
- const created = (node as { created?: string }).created ?? '';
- const modified = _modifiedAt(pt, pos, commitDates);
- const file = { ...node, size, lines: entry?.lines ?? 0, modified } as TreeNode;
-
- into.count += 1;
- into.fileCount += 1;
- into.size += size;
- into.createdMin = _minIso(into.createdMin, created);
- into.modifiedMax = _maxIso(into.modifiedMax, modified);
- const ext = (node as { extension?: string }).extension;
- _addExt(into, ext ? ext.toLowerCase() : null, 1, size);
- return file;
- }
-
- const own = _emptyRollup();
- const files: TreeNode[] = [];
- const subdirs: TreeNode[] = [];
- for (const child of node.children ?? []) {
- const kept = _rebuildAt(child, pos, timelines, commitDates, own);
- if (!kept) continue;
- (kept.type === NodeKind.File ? files : subdirs).push(kept);
- }
- if (files.length === 0 && subdirs.length === 0) return null;
-
- into.count += 1 + own.count;
- into.fileCount += own.fileCount;
- into.dirCount += 1 + own.dirCount;
- into.size += own.size;
- into.createdMin = _minIso(into.createdMin, own.createdMin);
- into.modifiedMax = _maxIso(into.modifiedMax, own.modifiedMax);
- for (const [ext, { count, size }] of own.ext) _addExt(into, ext, count, size);
-
- const children = [...files, ...subdirs];
- return {
- ...node,
- children,
- children_count: children.length,
- children_file_count: files.length,
- children_dir_count: subdirs.length,
- descendants_count: own.count,
- descendants_file_count: own.fileCount,
- descendants_dir_count: own.dirCount,
- descendants_size: own.size,
- descendants_created_min: own.createdMin,
- descendants_modified_max: own.modifiedMax,
- descendants_ext_breakdown: _extBreakdown(own),
- } 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;
-}
diff --git a/app/src/types/building.ts b/app/src/types/building.ts
deleted file mode 100644
index 8cc2739d8..000000000
--- a/app/src/types/building.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-// types/building.ts — pairs with state/stores/settings/buildings.ts (the tunables).
-// Defines the Building shape the layout step produces; the city renderer
-// (city/index.ts createCity) reads it to instantiate three.js meshes.
-
-import type { FileNode } from './manifest';
-
-/** Direction the door faces. Layout sets this; engine reads it. */
-export enum BuildingOrient {
- North = 'n',
- South = 's',
- East = 'e',
- West = 'w',
-}
-
-/**
- * One building in the laid-out city.
- * x, y — center on the ground plane (world units, y is along the second axis)
- * w, d — footprint width (x-axis) and depth (z-axis)
- * h — height (y-axis, world coords)
- * color — HSL string computed by colors.ts
- * file — the manifest file node this building represents
- * floors — stamped during layout for label/tooltip use
- */
-export interface Building {
- x: number;
- y: number;
- w: number;
- d: number;
- h: number;
- color: string;
- /**
- * 0..1 weathering signal — 0 = file created most recently in the repo,
- * 1 = file created earliest. Normalized against the repo's
- * minCreated/maxCreated so the scale rescales as the codebase ages.
- * Independent of `color` (which keys on last-modified): a bright,
- * recently-edited file can still be highly weathered if it was
- * originally created long ago.
- *
- * Optional because layout produces buildings without it; the
- * world post-layout step fills it in after the manifest's
- * date ranges are known. Buffer-builder defaults to 0 when absent.
- */
- createdAge?: number;
-
- /**
- * 0..1 staleness signal — 0 = file modified most recently in the repo,
- * 1 = file modified earliest. Normalized against the repo's
- * minModified/maxModified so the scale rescales as the codebase ages.
- * Mirror of `createdAge` but on the modified-date axis.
- *
- * Optional because layout produces buildings without it; the
- * world post-layout step fills it in after the manifest's
- * date ranges are known. Buffer-builder defaults to 0 when absent.
- */
- modifiedAge?: number;
- file: FileNode;
- orient: BuildingOrient;
- floors?: number;
-
- /**
- * Cell ID for spatial grid bucketing. Set by CellTile insertion.
- * Used by BuildingIndex for reverse lookup (raycaster hit → building).
- */
- cellId?: number;
-
- /**
- * Slot ID within a cell's InstancedMesh. Set by CellTile insertion.
- * Combined with cellId as "cellId:slotId" key for byCellSlot index.
- */
- slotId?: number;
-}
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index ea669615e..96fe9d0bf 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -1,12 +1,8 @@
// types/index.ts — barrel re-export. Lets callers do
-// import { NodeKind, FileNode, Building } from '@/types';
+// import { NodeKind, FileNode } from '@/types';
+import { Building } from '@/city/scene/types';
// without remembering which subfile owns what.
export * from './manifest';
export * from './timeline';
-export * from './building';
-export * from './street';
-export * from './scene';
-export * from './picker';
-export * from './animation';
export * from './ui';
diff --git a/app/src/types/manifest.contract.ts b/app/src/types/manifest.contract.ts
index fc4cb6bf8..bcf2ea3fc 100644
--- a/app/src/types/manifest.contract.ts
+++ b/app/src/types/manifest.contract.ts
@@ -2,7 +2,7 @@
// What a derived type can't check: our hand-written enums still match the wire.
import type { components } from './manifest.generated';
import { NodeKind } from './manifest';
-import { ScanPhase, CloneStage } from '@/api/manifest';
+import { ScanPhase, CloneStage } from '@/city/session/api/manifest';
import { TimelineStage } from './timeline';
type Schemas = components['schemas'];
diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts
index d57820849..4487570e2 100644
--- a/app/src/types/manifest.generated.ts
+++ b/app/src/types/manifest.generated.ts
@@ -264,19 +264,6 @@ export interface components {
* @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 */
@@ -353,17 +340,6 @@ export interface components {
/** Message */
message: string;
};
- /** DateRangeMs */
- DateRangeMs: {
- /** Mincreated */
- minCreated: number;
- /** Maxcreated */
- maxCreated: number;
- /** Minmodified */
- minModified: number;
- /** Maxmodified */
- maxModified: number;
- };
/** DateRanges */
DateRanges: {
/**
@@ -684,7 +660,6 @@ export interface components {
* @description Commits in the full history, however many `commits` carries
*/
commitCount: number;
- commitDates: components["schemas"]["CommitDateRange"];
maxCommitsPerDay: components["schemas"]["DayLeader"] | null;
/** Maxcommitstreakdays */
maxCommitStreakDays: number;
@@ -753,8 +728,6 @@ export interface components {
};
/** Commitlineranges */
commitLineRanges: components["schemas"]["RangeStat"][];
- /** Commitdateranges */
- commitDateRanges: components["schemas"]["DateRangeMs"][];
/** Notes */
notes: string[];
};
diff --git a/app/src/types/manifest.ts b/app/src/types/manifest.ts
index ce5fcfe12..0eb7b268f 100644
--- a/app/src/types/manifest.ts
+++ b/app/src/types/manifest.ts
@@ -33,7 +33,6 @@ export type RangeStat = Schemas['RangeStat'];
export type FileLeader = Schemas['FileLeader'];
export type DirLeader = Schemas['DirLeader'];
export type CommitLeader = Schemas['CommitLeader'];
-export type CommitDateRange = Schemas['CommitDateRange'];
export type DayLeader = Schemas['DayLeader'];
export type AuthorStat = Schemas['AuthorStat'];
export type RepoStats = Schemas['RepoStats'];
diff --git a/app/src/types/ui.ts b/app/src/types/ui.ts
index a0dab4a54..b50655ebe 100644
--- a/app/src/types/ui.ts
+++ b/app/src/types/ui.ts
@@ -3,9 +3,20 @@
import type { SourceKind } from '@/utils/sources';
import type { LoadingStep } from '@/constants/progress';
-import type { ScanErrorCode } from '@/api/manifest';
+import type { ScanErrorCode } from '@/city/session/api/manifest';
/** Left-sidebar tab IDs. Discriminator on the activity bar's mounted pane. */
+/** One stat on a selection pane's bottom row, or in a hover tooltip: the two
+ * read the same numbers because they build them from the same items. */
+export interface StatItem {
+ text: string;
+ /** Hover tooltip, for a value the short form abbreviates (an exact date).
+ * Defaults to the text, so a truncated item can still be read in full. */
+ title?: string;
+ /** How readily this item gives up width. Higher yields first; default 1. */
+ shrink?: number;
+}
+
export enum SidebarTab {
Explore = 'explore',
Search = 'search',
@@ -17,6 +28,9 @@ export enum SidebarTab {
export interface SourcePayload {
src: string;
branch?: string;
+ /** What to call it until the server says: whoever opened it may already know
+ * (a recents row does), and the overlay is up before the first event. */
+ label?: string;
/** When true, this open forces a fresh scan (server-side ?no_cache=1).
* Not persisted — re-opening from a recent uses cached scan by default. */
skipCache?: boolean;
@@ -34,7 +48,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/src/components/panes/PaneStats/statItems.ts b/app/src/utils/statItems.ts
similarity index 77%
rename from app/src/components/panes/PaneStats/statItems.ts
rename to app/src/utils/statItems.ts
index 38a7501f4..1c8a4b56d 100644
--- a/app/src/components/panes/PaneStats/statItems.ts
+++ b/app/src/utils/statItems.ts
@@ -1,13 +1,13 @@
-// components/panes/PaneStats/statItems.ts — Builds the stat rows the file and road
-// panes hand to . Pure: the caller supplies the node, so the same
-// formatting rules serve both panes without either reaching for the picker.
+// utils/statItems.ts — the stat rows a file or a road reports. Pure: the caller
+// supplies the node, so the selection panes and the city's hover tooltip read
+// the same numbers without either reaching for the picker.
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 { PaneStatItem } from './PaneStats';
+import type { TimelineStore } from '@/city/session/stores/timeline';
+import type { StatItem } from '@/types/ui';
/** Direct and recursive counts as one item, collapsing to a single number when
* a folder has no subfolders to distinguish them. */
@@ -15,7 +15,7 @@ function countItem(
direct: number | null | undefined,
total: number | null | undefined,
label: string
-): PaneStatItem | null {
+): StatItem | null {
if (direct == null && total == null) return null;
if (direct == null) return { text: `${total} ${label}`, title: `${total} total` };
if (total == null || direct === total) {
@@ -28,7 +28,7 @@ function countItem(
}
/** Relative age with the exact date as its tooltip. */
-function ageItem(iso: string, label: string, now: number): PaneStatItem {
+function ageItem(iso: string, label: string, now: number): StatItem {
return {
text: `${label} ${formatRelativeAgeShort(parseDateMs(iso), now)}`,
title: `${label} ${formatShortDate(iso)}`,
@@ -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 items: PaneStatItem[] = [];
+export function fileStatItems(file: FileNode, opts: FileStatOpts = {}): StatItem[] {
+ const { dates = true, now = Date.now(), timeline = null } = opts;
+ const items: StatItem[] = [];
// 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;
@@ -73,10 +76,10 @@ export function fileStatItems(file: FileNode, opts: FileStatOpts = {}): PaneStat
return items;
}
-export function directoryStatItems(dir: DirNode): PaneStatItem[] {
+export function directoryStatItems(dir: DirNode): StatItem[] {
// Lead with the kind: a folder named `app` and a file named `app` look alike,
// and the counts that follow only make sense once you know which this is.
- const items: PaneStatItem[] = [{ text: 'directory', shrink: 200 }];
+ const items: StatItem[] = [{ text: 'directory', shrink: 200 }];
const files = countItem(dir.children_file_count, dir.descendants_file_count, 'files');
if (files) items.push(files);
const dirs = countItem(dir.children_dir_count, dir.descendants_dir_count, 'dirs');
diff --git a/app/src/views/CityView/CityView.tsx b/app/src/views/CityView/CityView.tsx
index e8ebb348e..bbe1220fe 100644
--- a/app/src/views/CityView/CityView.tsx
+++ b/app/src/views/CityView/CityView.tsx
@@ -4,7 +4,6 @@
// and the syntax theme the file preview reads.
import './CityView.css';
-import { useEffect } from 'preact/hooks';
import { useSignalEffect } from '@preact/signals';
import { CityHeader } from './chrome/CityHeader/CityHeader';
@@ -16,38 +15,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 { useCity } from '@/city/CityProvider';
export function CityView() {
+ const session = useCity();
+ 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 +48,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} />