From b75543c2f93cd07f577c37613f073b4d4a23c55f Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 26 Jul 2026 13:58:28 -0400 Subject: [PATCH 01/37] Break the axe singleton cascade in the a11y audit (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timed-out axe.run never settles, so axe's `_running` latch stays set and every later surface throws "Axe is already running" — one slow surface bounced the whole push. axe.teardown() alone does not fix this: it clears the caches, element tree and selector data, but leaves `_running` untouched. Releasing that latch is what actually breaks the cascade, so afterEach does both. `_running` is absent from axe's public types, hence the cast. Adds a regression test that wedges the latch and asserts the next scan still runs; it fails with teardown() alone. Closes #108 --- app/tests/a11y/audit.test.tsx | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/app/tests/a11y/audit.test.tsx b/app/tests/a11y/audit.test.tsx index 3085f114..42a45136 100644 --- a/app/tests/a11y/audit.test.tsx +++ b/app/tests/a11y/audit.test.tsx @@ -53,6 +53,17 @@ interface Surface { axeMount?: (c: HTMLElement) => void; } +// axe is a process-wide singleton guarded by an `_running` latch. A run that +// vitest aborts mid-flight (timeout) never settles, so the latch stays set and +// every later `axe.run` throws "Axe is already running" — one slow surface +// fails all of them. `axe.teardown()` clears the caches and element tree but +// NOT that latch, so releasing it is the only thing that actually breaks the +// cascade. `_running` is deliberately absent from axe's public types. +function resetAxe(): void { + axe.teardown(); + (axe as unknown as { _running: boolean })._running = false; +} + const SURFACES: Surface[] = [ { name: 'ControlsPane', @@ -113,6 +124,7 @@ describe('accessibility audit (issue #79)', () => { } closeDebug(); closeShortcuts(); + resetAxe(); }); function mountSurface(mount: (c: HTMLElement) => void): HTMLElement { @@ -124,9 +136,9 @@ describe('accessibility audit (issue #79)', () => { } for (const surface of SURFACES) { - // Generous timeout: the settings DOM is large and CI is slower than a dev - // box. axe is also a singleton — a run that times out mid-flight leaves it - // "running" and the next surface throws, so it must be allowed to finish. + // Generous timeout: the settings DOM is large, and the gate runs pytest + + // vitest + coverage concurrently, so wall-clock here is far worse than on a + // dev box. Blowing it now fails only this surface — afterEach unwedges axe. it(`${surface.name}: no axe violations`, async () => { const c = mountSurface(surface.axeMount ?? surface.mount); const results = await axe.run(c, { @@ -171,4 +183,18 @@ describe('accessibility audit (issue #79)', () => { expect(positiveTab).toHaveLength(0); }); } + + // Guards the cascade fix: a timed-out run leaves `_running` set, which used + // to make every subsequent surface throw instead of scanning. + it('a wedged axe latch does not poison the next surface (issue #108)', async () => { + (axe as unknown as { _running: boolean })._running = true; + resetAxe(); + + const c = mountSurface(SURFACES[SURFACES.length - 1].mount); + const results = await axe.run(c, { + resultTypes: ['violations'], + rules: { 'color-contrast': { enabled: false }, region: { enabled: false } }, + }); + expect(results.violations).toEqual([]); + }); }); From 407c039ab56f64a1ac439c326cb8b93ca788a7ed Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 26 Jul 2026 13:58:37 -0400 Subject: [PATCH 02/37] Serve syntax themes from the bundle, not jsDelivr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The syntax-highlighting stylesheet was fetched from cdn.jsdelivr.net on every theme change, which told a third party the viewer's IP, user agent and chosen theme, broke the app offline, and trusted un-pinned remote CSS (attribute selectors with url() can exfiltrate, so this was not purely cosmetic). highlight.js is already a direct dependency and ships these stylesheets, so they now come from the bundle same-origin — matching how constants/fileIcons.ts already sources its Material icons. `?url` keeps each theme a separate asset rather than inlining all 21 into the JS bundle, so the browser still fetches only the selected one. SYNTAX_THEME_OPTIONS becomes `as const` so its values form a literal union; THEME_HREF is keyed on that union, making a theme without a stylesheet a typecheck failure instead of a silently unstyled pane. --- .../HljsThemeLink/HljsThemeLink.tsx | 73 ++++++++++++++++++- app/src/state/stores/settings/syntaxTheme.ts | 28 ++++--- app/tests/components/hljsThemeLink.test.tsx | 28 +++++++ 3 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 app/tests/components/hljsThemeLink.test.tsx diff --git a/app/src/components/HljsThemeLink/HljsThemeLink.tsx b/app/src/components/HljsThemeLink/HljsThemeLink.tsx index cf3d1ffe..0a1f1186 100644 --- a/app/src/components/HljsThemeLink/HljsThemeLink.tsx +++ b/app/src/components/HljsThemeLink/HljsThemeLink.tsx @@ -3,14 +3,79 @@ // document.head via Preact's createPortal so the syntax-highlighting CSS // follows the SYNTAX_THEME signal automatically — no module-load effect, // no manual element management. +// +// The stylesheets are bundled from the pinned highlight.js dependency and +// served same-origin, matching how constants/fileIcons.ts sources its Material +// icons. They were previously fetched from jsDelivr per theme change, which +// told a third party the viewer's IP and theme choice, broke offline use, and +// trusted un-pinned remote CSS (attribute-selector `url()` rules can exfiltrate). +// +// `?url` emits each theme as its own asset rather than inlining all 21 into the +// JS bundle, so the browser only fetches the one that's selected. import './HljsThemeLink.css'; import { createPortal } from 'preact/compat'; -import { SYNTAX_THEME } from '@/state/stores/settings/syntaxTheme'; +import { + SYNTAX_THEME, + SYNTAX_THEME_DEFAULT, + type SyntaxThemeValue, +} from '@/state/stores/settings/syntaxTheme'; -const HLJS_VERSION = '11.11.1'; +import a11yDark from 'highlight.js/styles/a11y-dark.min.css?url'; +import agate from 'highlight.js/styles/agate.min.css?url'; +import androidstudio from 'highlight.js/styles/androidstudio.min.css?url'; +import atomOneDark from 'highlight.js/styles/atom-one-dark.min.css?url'; +import cybertopiaCherry from 'highlight.js/styles/cybertopia-cherry.min.css?url'; +import cybertopiaIcecap from 'highlight.js/styles/cybertopia-icecap.min.css?url'; +import dracula from 'highlight.js/styles/base16/dracula.min.css?url'; +import githubDark from 'highlight.js/styles/github-dark.min.css?url'; +import irBlack from 'highlight.js/styles/ir-black.min.css?url'; +import monokai from 'highlight.js/styles/monokai.min.css?url'; +import monokaiSublime from 'highlight.js/styles/monokai-sublime.min.css?url'; +import nightOwl from 'highlight.js/styles/night-owl.min.css?url'; +import nord from 'highlight.js/styles/nord.min.css?url'; +import obsidian from 'highlight.js/styles/obsidian.min.css?url'; +import rosePine from 'highlight.js/styles/rose-pine.min.css?url'; +import rosePineMoon from 'highlight.js/styles/rose-pine-moon.min.css?url'; +import shadesOfPurple from 'highlight.js/styles/shades-of-purple.min.css?url'; +import solarizedDark from 'highlight.js/styles/base16/solarized-dark.min.css?url'; +import stackoverflowDark from 'highlight.js/styles/stackoverflow-dark.min.css?url'; +import tokyoNightDark from 'highlight.js/styles/tokyo-night-dark.min.css?url'; +import vs2015 from 'highlight.js/styles/vs2015.min.css?url'; + +// Keyed on SyntaxThemeValue, so adding an option without its stylesheet is a +// typecheck failure rather than a silently unstyled pane. +export const THEME_HREF: Record = { + 'a11y-dark': a11yDark, + agate, + androidstudio, + 'atom-one-dark': atomOneDark, + 'cybertopia-cherry': cybertopiaCherry, + 'cybertopia-icecap': cybertopiaIcecap, + 'base16/dracula': dracula, + 'github-dark': githubDark, + 'ir-black': irBlack, + monokai, + 'monokai-sublime': monokaiSublime, + 'night-owl': nightOwl, + nord, + obsidian, + 'rose-pine': rosePine, + 'rose-pine-moon': rosePineMoon, + 'shades-of-purple': shadesOfPurple, + 'base16/solarized-dark': solarizedDark, + 'stackoverflow-dark': stackoverflowDark, + 'tokyo-night-dark': tokyoNightDark, + vs2015, +}; + +export function hrefForTheme(theme: string): string { + return THEME_HREF[theme as SyntaxThemeValue] ?? THEME_HREF[SYNTAX_THEME_DEFAULT]; +} export function HljsThemeLink() { - const href = `https://cdn.jsdelivr.net/npm/highlight.js@${HLJS_VERSION}/styles/${SYNTAX_THEME.value}.min.css`; - return createPortal(, document.head); + return createPortal( + , + document.head + ); } diff --git a/app/src/state/stores/settings/syntaxTheme.ts b/app/src/state/stores/settings/syntaxTheme.ts index e2752d64..7c35f63b 100644 --- a/app/src/state/stores/settings/syntaxTheme.ts +++ b/app/src/state/stores/settings/syntaxTheme.ts @@ -12,11 +12,14 @@ export interface SyntaxThemeOption { label: string; } -// Curated DARK theme list. All filenames verified against -// node_modules/highlight.js/styles/ (highlight.js 11.11.1). Light themes -// are intentionally not offered — codecity's UI is a dark theme and bright -// code panels clash visually. Alphabetized by display label. -export const SYNTAX_THEME_OPTIONS: SyntaxThemeOption[] = [ +// Curated DARK theme list. Light themes are intentionally not offered — +// codecity's UI is a dark theme and bright code panels clash visually. +// Alphabetized by display label. +// +// `as const` makes the values a literal union, which HljsThemeLink's stylesheet +// map is keyed on: adding a theme here fails typecheck until its CSS is +// imported there, so the two cannot drift. +export const SYNTAX_THEME_OPTIONS = [ { value: 'a11y-dark', label: 'A11y Dark' }, { value: 'agate', label: 'Agate' }, { value: 'androidstudio', label: 'Android Studio' }, @@ -38,13 +41,18 @@ export const SYNTAX_THEME_OPTIONS: SyntaxThemeOption[] = [ { value: 'stackoverflow-dark', label: 'Stack Overflow Dark' }, { value: 'tokyo-night-dark', label: 'Tokyo Night' }, { value: 'vs2015', label: 'VS 2015' }, -]; +] as const satisfies readonly SyntaxThemeOption[]; + +export type SyntaxThemeValue = (typeof SYNTAX_THEME_OPTIONS)[number]['value']; // Default matches the hand-rolled theme bundled in HljsThemeLink.css. -// Changing to a CDN theme overrides those .hljs-* rules because the -// is injected into after the bundled CSS (later in the cascade wins -// at equal specificity, and these are the same specificity). -export const SYNTAX_THEME_DEFAULT = 'atom-one-dark'; +// Selecting a theme overrides those .hljs-* rules because the is +// injected into after the bundled CSS (later in the cascade wins at +// equal specificity, and these are the same specificity). +export const SYNTAX_THEME_DEFAULT: SyntaxThemeValue = 'atom-one-dark'; +// Typed `string`, not SyntaxThemeValue: the persisted value comes from +// localStorage and may name a theme from an older build. HljsThemeLink +// resolves anything unrecognized back to the default. export const SYNTAX_THEME = persistedSignal('SYNTAX_THEME', SYNTAX_THEME_DEFAULT); // SYNTAX_THEME is a setting (it lives in the Appearance tab) but uses a plain diff --git a/app/tests/components/hljsThemeLink.test.tsx b/app/tests/components/hljsThemeLink.test.tsx new file mode 100644 index 00000000..03ecdc1a --- /dev/null +++ b/app/tests/components/hljsThemeLink.test.tsx @@ -0,0 +1,28 @@ +// Note on scope: vitest stubs CSS assets, so `?url` imports evaluate to "" here +// and asserting on the resolved URLs would pass vacuously. Two other things do +// the real enforcing: THEME_HREF is typed `Record`, so +// a missing theme fails typecheck, and the hrefs come from bundler-resolved +// imports, which cannot produce an off-origin URL the way the old hardcoded +// jsDelivr template string did. What's left to test at runtime is the lookup. + +import { describe, it, expect } from 'vitest'; +import { THEME_HREF, hrefForTheme } from '@/components/HljsThemeLink/HljsThemeLink'; +import { SYNTAX_THEME_OPTIONS, SYNTAX_THEME_DEFAULT } from '@/state/stores/settings/syntaxTheme'; + +describe('hljs theme stylesheets', () => { + it('has a stylesheet entry for every offered theme', () => { + const missing = SYNTAX_THEME_OPTIONS.filter((o) => !(o.value in THEME_HREF)); + expect(missing.map((o) => o.value)).toEqual([]); + }); + + it('offers a theme for every stylesheet entry, with no strays', () => { + const offered = new Set(SYNTAX_THEME_OPTIONS.map((o) => o.value)); + expect(Object.keys(THEME_HREF).filter((k) => !offered.has(k))).toEqual([]); + }); + + it('falls back to the default for a theme name it does not recognize', () => { + // A persisted setting can name a theme that a later build dropped; that + // must resolve to the default entry rather than an undefined href. + expect(hrefForTheme('theme-from-an-older-build')).toBe(THEME_HREF[SYNTAX_THEME_DEFAULT]); + }); +}); From eb9ab180ff784540d92116e3b6683f7a3257eee6 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 26 Jul 2026 14:00:01 -0400 Subject: [PATCH 03/37] Bind the API to loopback by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--host` defaulted to 0.0.0.0, so a pip-installed `codecity` served every interface. The API has no authentication, and once a manifest scan registers a root, /api/file will serve anything beneath it — on shared wifi that hands the scanned tree to whoever asks. Local scanning is gated behind CODECITY_ALLOW_LOCAL_REPOS, but anyone pointing codecity at their own repos has already turned that on. Default is now 127.0.0.1, with --host 0.0.0.0 available to opt in. Containers need the wide bind and now ask for it explicitly: in a container 0.0.0.0 is the container's own namespace, and only published ports are reachable. docker-compose.dev.yml repeats the flag because its `command:` replaces the Dockerfile CMD outright rather than appending to it. --- Dockerfile | 7 ++++++- api/__main__.py | 20 +++++++++++++++----- api/tests/test_cli.py | 20 ++++++++++++++++++++ docker-compose.dev.yml | 5 ++++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2e550d91..1c34a4a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,7 +92,12 @@ HEALTHCHECK --interval=10s --timeout=2s --start-period=3s --retries=3 \ # process by design, see api/security.py (the allowed_roots trust set is # in-memory; multi-worker would split it). ENTRYPOINT ["/srv/.venv/bin/python", "-m", "api"] -CMD ["--port", "8080"] +# --host is explicit because the CLI defaults to loopback (an unauthenticated +# API that serves any scanned root should not reach the network by default). +# In a container that default would make the port unreachable from the host: +# here 0.0.0.0 is the container's own namespace, and only published ports get +# out. Any `command:` override must repeat this — see docker-compose.dev.yml. +CMD ["--port", "8080", "--host", "0.0.0.0"] # Populated by CI via --build-arg. ARG GIT_SHA=dev diff --git a/api/__main__.py b/api/__main__.py index d0643570..f416bb7a 100644 --- a/api/__main__.py +++ b/api/__main__.py @@ -1,9 +1,10 @@ """api CLI entrypoint. - python -m api Serve on :8080 (single uvicorn process). - python -m api --port 8000 Override port. - python -m api --reload Auto-reload on source changes (dev only). - python -m api --version Print version. + python -m api Serve on 127.0.0.1:8080 (single process). + python -m api --port 8000 Override port. + python -m api --host 0.0.0.0 Expose on the network (see --host below). + python -m api --reload Auto-reload on source changes (dev only). + python -m api --version Print version. SINGLE PROCESS by design — see api/security.py (the allowed_roots trust set is in-memory; multi-worker would split it). No --workers flag. @@ -27,7 +28,16 @@ def _build_parser() -> argparse.ArgumentParser: ) p.add_argument("--version", action="version", version=f"codecity {__version__}") p.add_argument("--port", type=int, default=8080, help="HTTP port (default 8080).") - p.add_argument("--host", default="0.0.0.0", help="Bind host (default 0.0.0.0).") + # Loopback by default: the API is unauthenticated, and once a scan registers + # a root, /api/file serves anything under it. Binding every interface would + # hand the whole scanned tree to anyone on the same network. Containers pass + # --host 0.0.0.0 explicitly, since there the bind is the container's own + # namespace and only published ports are reachable. + p.add_argument( + "--host", + default="127.0.0.1", + help="Bind host (default 127.0.0.1; use 0.0.0.0 to expose on the network).", + ) p.add_argument("--reload", action="store_true", help="Auto-reload (dev only).") return p diff --git a/api/tests/test_cli.py b/api/tests/test_cli.py index 46f5072e..33d4d541 100644 --- a/api/tests/test_cli.py +++ b/api/tests/test_cli.py @@ -42,3 +42,23 @@ def test_main_invokes_uvicorn() -> None: run.assert_called_once() assert run.call_args.kwargs["port"] == 9999 assert run.call_args.kwargs["workers"] == 1 + + +def test_binds_loopback_by_default() -> None: + """The API is unauthenticated and serves any registered scan root, so the + default bind must not reach the network. Containers opt in explicitly.""" + from unittest import mock + from api.__main__ import main + + with mock.patch("api.__main__.uvicorn.run") as run: + assert main([]) == 0 + assert run.call_args.kwargs["host"] == "127.0.0.1" + + +def test_host_flag_can_opt_into_exposure() -> None: + from unittest import mock + from api.__main__ import main + + with mock.patch("api.__main__.uvicorn.run") as run: + assert main(["--host", "0.0.0.0"]) == 0 + assert run.call_args.kwargs["host"] == "0.0.0.0" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3f7a6f95..a03a58eb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,7 +11,10 @@ services: # The image ENTRYPOINT is `/srv/.venv/bin/python -m api`; this `command:` # supplies just the flags appended to it. Adds `--reload` so source edits # under ./api auto-restart uvicorn, and pins the port to 8000 for dev. - command: ["--reload", "--port", "8000"] + # `--host` repeats the Dockerfile CMD's value because this replaces CMD + # outright: without it the CLI's loopback default would make the api + # unreachable from the app container. + command: ["--reload", "--port", "8000", "--host", "0.0.0.0"] volumes: # Bind source for auto-reload. Read-only inside the container — host # owns the files. From f89ab3d810db31fe6055a9663c93bfe79388d4d5 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 26 Jul 2026 14:04:26 -0400 Subject: [PATCH 04/37] Remove the unreachable manifest cache-clear surface (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DELETE /api/manifest/cache` had no caller. The frontend's clearManifestCache() was never invoked either — its only reference was a RecentsList test asserting it is never called, which is what surfaced it. Removing the endpoint made its whole backing chain dead: cache_clear_all (only the endpoint called it), cache_clear_manifests (only cache_clear_all), remove_clone (only the endpoint), and CacheClearResponse. cache_clear_timeline stays — a no_cache scan still calls it. Also drops api/tests/test_server_cache.py and the five cache_clear_manifests cases, and rewrites the RecentsList test around what it actually verifies (the confirm step), since the cache assertion no longer has a subject. Consequence worth knowing: nothing clears a per-root cache now. A corrupt manifest cache or clone is recovered by deleting ~/.cache/codecity, not through the API. Nothing exercised that path, so this removes an unused recovery route rather than a used one. Types regenerated via `just gen-types`. --- api/models/responses.py | 4 - api/routers/manifest.py | 37 +- api/services/cache.py | 57 +- api/services/clone.py | 12 - api/tests/test_cache.py | 48 - api/tests/test_server_cache.py | 93 - app/src/api/manifest.ts | 9 - app/src/types/manifest.generated.ts | 2092 ++++++++++----------- app/tests/components/RecentsList.test.tsx | 5 +- 9 files changed, 1030 insertions(+), 1327 deletions(-) delete mode 100644 api/tests/test_server_cache.py diff --git a/api/models/responses.py b/api/models/responses.py index 229a668b..46156ef2 100644 --- a/api/models/responses.py +++ b/api/models/responses.py @@ -40,10 +40,6 @@ class ConfigResponse(BaseModel): allowLocalRepos: bool -class CacheClearResponse(BaseModel): - deleted: int - - class CommitDetailResponse(BaseModel): sha: str authors: list[str] diff --git a/api/routers/manifest.py b/api/routers/manifest.py index 98f5790d..c8a52616 100644 --- a/api/routers/manifest.py +++ b/api/routers/manifest.py @@ -1,10 +1,9 @@ """The manifest routes: GET /api/manifest (SSE stream), GET -/api/manifest/signature, GET /api/timeline (SSE stream), DELETE -/api/manifest/cache. +/api/manifest/signature, GET /api/timeline (SSE stream). Source classification/resolution lives in api.services.source; these are the thin HTTP handlers over it. A ResolveError carries a status + message: the -signature/cache routes turn it into an HTTPException, while the manifest and +signature route turns it into an HTTPException, while the manifest and timeline SSE routes turn it into an `error` event (EventSource can't read 4xx bodies).""" @@ -32,10 +31,8 @@ TimelineProgressEvent, ) from api.models.manifest import SignatureResponse -from api.models.responses import CacheClearResponse from api.security import TRUST from api.services.cache import ( - cache_clear_all, cache_clear_timeline, cache_load_manifest, cache_load_ref_manifest, @@ -49,11 +46,9 @@ CloneError, HostUnreachableError, RepoNotFoundError, - clone_dir_for, ensure_clone, fetch_lfs_history, hydrate_blobs, - remove_clone, ) from api.services.gitobj import resolve_ref from api.services.scan import ( @@ -247,34 +242,6 @@ def _run() -> None: return EventSourceResponse(gen()) -@router.delete("/manifest/cache", response_model=CacheClearResponse) -def clear_cache( - src: str = Query(...), - branch: str | None = Query(None), -) -> CacheClearResponse: - if not src: - raise HTTPException(400, "missing 'src' query param") - kind = classify(src) - if kind is SourceKind.INVALID: - raise HTTPException(400, "unrecognized source: pass a local path or a git URL") - if kind is SourceKind.REMOTE: - abs_root = clone_dir_for(src, branch) - else: - # Non-strict resolve so a recents entry for a since-deleted path - # still drops its cache. - abs_root = Path(src).resolve(strict=False) - # Full clean slate for this source: every per-root cache (manifest, - # file-stat, git-history). For a REMOTE source also delete the clone working - # tree so a re-add re-clones from scratch — the recovery path for a corrupt - # clone. Hold the clone lock so we never rmtree a clone a concurrent request - # is mid-clone into. - deleted = cache_clear_all(abs_root) - if kind is SourceKind.REMOTE: - with TRUST.clone_lock: - remove_clone(src, branch) - return CacheClearResponse(deleted=deleted) - - def _sse(event: "ScanEvent | TimelineEvent", payload: dict[str, Any]) -> dict[str, Any]: """sse-starlette event dict: {'event': name, 'data': json-string}. Both StrEnums serialize to their wire string ('manifest-complete', 'timeline- diff --git a/api/services/cache.py b/api/services/cache.py index 85a44df0..c285e565 100644 --- a/api/services/cache.py +++ b/api/services/cache.py @@ -393,10 +393,9 @@ def _manifest_cache_path(abs_root: Path, content_signature: str) -> Path: def _ref_manifest_cache_path(abs_root: Path, ref_sha: str) -> Path: - # `__ref-` (not `__`) so cache_clear_manifests's `{repo_key}__*.json.gz` - # glob still sweeps these alongside content-signature entries, while the - # prefix keeps a ref-sha visually distinct from a content signature in - # directory listings. + # `__ref-` prefix keeps a ref-sha visually distinct from a content + # signature in directory listings, while staying inside the + # `{repo_key}__*.json.gz` shape the other per-root globs match. return CACHE_ROOT / "manifests" / f"{repo_key(abs_root)}__ref-{ref_sha}.json.gz" @@ -482,8 +481,7 @@ def cache_save_manifest( def cache_load_ref_manifest(abs_root: Path, ref_sha: str) -> "Manifest | None": """Load the cached manifest for this (root, ref_sha). A resolved commit sha's manifest is immutable (the commit's content never changes), so - unlike the content-signature cache this key never needs invalidating — - only `cache_clear_manifests`/`cache_clear_all` remove it.""" + unlike the content-signature cache this key never needs invalidating.""" return _load_gz_manifest(_ref_manifest_cache_path(abs_root, ref_sha)) @@ -556,50 +554,3 @@ def cache_clear_timeline(abs_root: Path) -> int: except OSError: pass return count - - -def cache_clear_manifests(abs_root: Path) -> int: - """Delete every cached manifest file for this root, across all - signatures, every ref-keyed manifest, AND every timeline bundle (the - `__*.json.gz` glob below matches `__.json.gz`, - `__ref-.json.gz`, and `__timeline-.json.gz`). - Returns the count deleted. - - Silently ignores I/O errors per the rest of this module's hygiene — - cache cleanup failures must never break the response.""" - manifests_dir = CACHE_ROOT / "manifests" - if not manifests_dir.exists(): - return 0 - pattern = f"{repo_key(abs_root)}__*.json.gz" - count = 0 - for path in manifests_dir.glob(pattern): - try: - path.unlink() - count += 1 - except OSError: - pass - return count - - -def cache_clear_all(abs_root: Path) -> int: - """Delete EVERY per-root cache for this root — manifest (all - signatures), file-stat, git-history, and blob-stats. Returns the count - deleted. - - Backs the "clear cache" flow's clean-slate guarantee for a source. - The git clone working tree lives outside CACHE_ROOT, so the caller - removes it separately (see clone.remove_clone). Same swallow-errors - hygiene as the rest of this module — cleanup failures must never - break the response.""" - count = cache_clear_manifests(abs_root) - for path in ( - _file_cache_path(abs_root), - _git_history_cache_path(abs_root), - _blob_cache_path(abs_root), - ): - try: - path.unlink() - count += 1 - except OSError: - pass # missing file or I/O error — best-effort cleanup - return count diff --git a/api/services/clone.py b/api/services/clone.py index e07db4af..f050220d 100644 --- a/api/services/clone.py +++ b/api/services/clone.py @@ -70,7 +70,6 @@ class CloneInterruptedError(CloneError): "fetch_lfs_history", "hydrate_blobs", "list_remote_branches", - "remove_clone", ] @@ -855,17 +854,6 @@ def _partial_clone_filter(target: Path) -> str | None: return None -def remove_clone(url: str, branch: str | None) -> bool: - """Delete the cached clone working tree for ``(url, branch)``. Returns - True if a directory existed and was removed, False if there was nothing - to remove. Best-effort: rmtree errors are swallowed (ignore_errors).""" - target = clone_dir_for(url, branch) - if not target.exists(): - return False - shutil.rmtree(target, ignore_errors=True) - return True - - # ls-remote timeout: bounded so a black-holed remote can't wedge a request. # 20s covers a slow-but-live remote; a real hang trips it and surfaces a clean # HostUnreachableError to the caller. diff --git a/api/tests/test_cache.py b/api/tests/test_cache.py index 013637a3..8dd5e04d 100644 --- a/api/tests/test_cache.py +++ b/api/tests/test_cache.py @@ -508,29 +508,6 @@ def test_manifest_rejects_when_git_history_version_changed(self): # Loader must reject. self.assertIsNone(cache_load_manifest(root, sig)) - def test_clear_manifests_deletes_every_signature(self) -> None: - root = Path("/x") - manifest = self._make_manifest() - cache_mod.cache_save_manifest(root, "a" * 32, manifest) - cache_mod.cache_save_manifest(root, "b" * 32, manifest) - # Unrelated root — must NOT be deleted. - cache_mod.cache_save_manifest(Path("/y"), "c" * 32, manifest) - - deleted = cache_mod.cache_clear_manifests(root) - self.assertEqual(deleted, 2) - self.assertIsNone(cache_mod.cache_load_manifest(root, "a" * 32)) - self.assertIsNone(cache_mod.cache_load_manifest(root, "b" * 32)) - # Unrelated root's cache survives. - self.assertIsNotNone(cache_mod.cache_load_manifest(Path("/y"), "c" * 32)) - - def test_clear_manifests_no_entries_returns_zero(self) -> None: - self.assertEqual(cache_mod.cache_clear_manifests(Path("/never/scanned")), 0) - - def test_clear_manifests_missing_dir_returns_zero(self) -> None: - # CACHE_ROOT/manifests doesn't exist yet (no saves have happened). - self.assertFalse((cache_mod.CACHE_ROOT / "manifests").exists()) - self.assertEqual(cache_mod.cache_clear_manifests(Path("/x")), 0) - def test_ref_manifest_roundtrip(self) -> None: root = Path("/some/repo") sha = "a" * 40 @@ -543,19 +520,6 @@ def test_ref_manifest_load_missing_returns_none(self) -> None: cache_mod.cache_load_ref_manifest(Path("/never/scanned"), "b" * 40) ) - def test_clear_manifests_also_sweeps_ref_manifests(self) -> None: - # cache_clear_manifests's `{repo_key}__*.json.gz` glob covers BOTH - # content-signature and `__ref-` keyed files. - root = Path("/x") - manifest = self._make_manifest() - cache_mod.cache_save_manifest(root, "a" * 32, manifest) - cache_mod.cache_save_ref_manifest(root, "b" * 40, manifest) - - deleted = cache_mod.cache_clear_manifests(root) - self.assertEqual(deleted, 2) - self.assertIsNone(cache_mod.cache_load_manifest(root, "a" * 32)) - self.assertIsNone(cache_mod.cache_load_ref_manifest(root, "b" * 40)) - def _make_bundle(self) -> dict: return { "commits": [], @@ -617,18 +581,6 @@ def test_timeline_excludes_key_separately(self) -> None: cache_mod.cache_load_timeline(root, sha, frozenset({"other"})) ) - def test_clear_manifests_also_sweeps_timeline(self) -> None: - # cache_clear_manifests's `{repo_key}__*.json.gz` glob covers - # content-signature, `__ref-`, AND `__timeline-` keyed files. - root = Path("/x") - cache_mod.cache_save_manifest(root, "a" * 32, self._make_manifest()) - cache_mod.cache_save_timeline(root, "b" * 40, self._make_bundle()) - - deleted = cache_mod.cache_clear_manifests(root) - self.assertEqual(deleted, 2) - self.assertIsNone(cache_mod.cache_load_manifest(root, "a" * 32)) - self.assertIsNone(cache_mod.cache_load_timeline(root, "b" * 40)) - def test_clear_timeline_evicts_all_heads_only(self) -> None: # A no_cache scan clears every timeline bundle for the root (all HEADs) # but leaves the manifest caches untouched. diff --git a/api/tests/test_server_cache.py b/api/tests/test_server_cache.py deleted file mode 100644 index abbc4337..00000000 --- a/api/tests/test_server_cache.py +++ /dev/null @@ -1,93 +0,0 @@ -"""TestClient coverage for DELETE /api/manifest/cache.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - -from api.app import create_app -from api.services.cache import cache_save_manifest -from api.services.scan import signature_tree - - -def _git(*a: str, cwd: Path) -> None: - subprocess.run(["git", *a], cwd=cwd, check=True, capture_output=True) - - -@pytest.fixture() -def repo(tmp_path: Path) -> Path: - p = tmp_path / "repo" - p.mkdir() - _git("init", "-q", cwd=p) - _git("config", "user.email", "a@b.c", cwd=p) - _git("config", "user.name", "T", cwd=p) - (p / "f.txt").write_text("x") - _git("add", ".", cwd=p) - _git("commit", "-qm", "c", cwd=p) - return p - - -@pytest.fixture() -def client(tmp_path: Path, redirect_cache_root) -> TestClient: - static = tmp_path / "static" - static.mkdir() - (static / "index.html").write_text("x") - return TestClient(create_app(static_dir=static)) - - -def test_cache_missing_src(client: TestClient) -> None: - assert client.delete("/api/manifest/cache").status_code in (400, 422) - - -def test_cache_invalid_src_400(client: TestClient) -> None: - r = client.delete("/api/manifest/cache", params={"src": "neither-path-nor-url"}) - assert r.status_code == 400 - - -def test_cache_clears_warmed_local_source(client: TestClient, repo: Path) -> None: - # Warm the cache directly via the service layer (no SSE stream yet). - sig = signature_tree(str(repo), use_cache=False)["content_signature"] - cache_save_manifest(repo.resolve(), sig, {"root": str(repo)}) # type: ignore[arg-type] - r = client.delete("/api/manifest/cache", params={"src": str(repo)}) - assert r.status_code == 200 - assert r.json()["deleted"] >= 1 - - -def test_cache_delete_not_gated_by_local_repos( - client: TestClient, repo: Path, monkeypatch -) -> None: - # Cache-delete must work even when local repos are disabled. - monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) - r = client.delete("/api/manifest/cache", params={"src": str(repo)}) - assert r.status_code == 200 - assert "deleted" in r.json() - - -def test_cache_clear_removes_remote_clone_dir(client: TestClient) -> None: - # For a REMOTE source, clearing the cache also deletes the clone working - # tree so a re-add re-clones from scratch (the corrupt-clone recovery path). - from api.services import clone as clone_mod - - url = "https://example.com/owner/repo.git" - clone_dir = clone_mod.clone_dir_for(url, None) - clone_dir.mkdir(parents=True) - (clone_dir / "marker.txt").write_text("x") - r = client.delete("/api/manifest/cache", params={"src": url}) - assert r.status_code == 200 - assert not clone_dir.exists(), "remote clone dir should be removed on cache clear" - - -def test_cache_clear_does_not_delete_local_project( - client: TestClient, repo: Path -) -> None: - # A LOCAL source's actual project directory must NEVER be deleted — only - # its per-root caches under CACHE_ROOT are dropped. - sig = signature_tree(str(repo), use_cache=False)["content_signature"] - cache_save_manifest(repo.resolve(), sig, {"root": str(repo)}) # type: ignore[arg-type] - r = client.delete("/api/manifest/cache", params={"src": str(repo)}) - assert r.status_code == 200 - assert repo.is_dir(), "local project directory must not be deleted" - assert (repo / "f.txt").is_file() diff --git a/app/src/api/manifest.ts b/app/src/api/manifest.ts index f624d7b3..e202fd76 100644 --- a/app/src/api/manifest.ts +++ b/app/src/api/manifest.ts @@ -220,12 +220,3 @@ export function streamManifest( }, }; } - -/** - * Clear the server-side scan cache for one (src, branch) pair. Best-effort — - * failures are swallowed (cache-clear is a UX nicety, not a correctness path). - */ -export function clearManifestCache(src: string, branch?: string): void { - const url = apiUrl('manifest/cache', { [URL_PARAMS.SRC]: src, [URL_PARAMS.BRANCH]: branch }); - fetch(url, { method: 'DELETE' }).catch(() => {}); -} diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts index dbc34339..e2fbc730 100644 --- a/app/src/types/manifest.generated.ts +++ b/app/src/types/manifest.generated.ts @@ -4,1096 +4,1050 @@ */ export interface paths { - "/api/health": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Health */ - get: operations["health_api_health_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/config": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Config */ - get: operations["config_api_config_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/file": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get File */ - get: operations["get_file_api_file_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/images": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Get Images - * @description Batch image fetch — {path: {mime, b64}} for many small images in one round - * trip. NOT a plural of GET /api/file: it inlines base64, serves images only, - * and omits anything it can't serve. It exists so the scene's billboard loader - * doesn't exhaust the browser's HTTP/1.1 connection pool on a media-heavy repo. - * - * Each path is trust-checked exactly like GET /api/file. Paths that are out of - * root, missing, non-image, or larger than _MAX_BATCH_IMAGE_BYTES are silently - * omitted; the client falls back to the streaming GET for those. Videos are - * never batched (they stream their poster frame), so this is images only. - */ - post: operations["get_images_api_images_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/fingerprints": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Get Fingerprints - * @description Batch byte-pattern fingerprint fetch — {path: {b64}}, one round trip for - * many buildings. Trust-checked like GET /api/file; out-of-root / missing / - * unreadable paths are silently omitted. Raw binary bytes never leave the - * server — only the head is read, and only the fingerprint image returned. - */ - post: operations["get_fingerprints_api_fingerprints_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/commit": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Commit */ - get: operations["get_commit_api_commit_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/branches": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Branches */ - get: operations["get_branches_api_branches_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/manifest/signature": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Signature */ - get: operations["signature_api_manifest_signature_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/timeline": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Timeline */ - get: operations["timeline_api_timeline_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/manifest/cache": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Clear Cache */ - delete: operations["clear_cache_api_manifest_cache_delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/manifest": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Manifest */ - get: operations["manifest_api_manifest_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; + '/api/health': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations['health_api_health_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/config': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Config */ + get: operations['config_api_config_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/file': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get File */ + get: operations['get_file_api_file_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/images': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Images + * @description Batch image fetch — {path: {mime, b64}} for many small images in one round + * trip. NOT a plural of GET /api/file: it inlines base64, serves images only, + * and omits anything it can't serve. It exists so the scene's billboard loader + * doesn't exhaust the browser's HTTP/1.1 connection pool on a media-heavy repo. + * + * Each path is trust-checked exactly like GET /api/file. Paths that are out of + * root, missing, non-image, or larger than _MAX_BATCH_IMAGE_BYTES are silently + * omitted; the client falls back to the streaming GET for those. Videos are + * never batched (they stream their poster frame), so this is images only. + */ + post: operations['get_images_api_images_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/fingerprints': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Fingerprints + * @description Batch byte-pattern fingerprint fetch — {path: {b64}}, one round trip for + * many buildings. Trust-checked like GET /api/file; out-of-root / missing / + * unreadable paths are silently omitted. Raw binary bytes never leave the + * server — only the head is read, and only the fingerprint image returned. + */ + post: operations['get_fingerprints_api_fingerprints_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/commit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; + /** Get Commit */ + get: operations['get_commit_api_commit_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/branches': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Branches */ + get: operations['get_branches_api_branches_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/manifest/signature': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Signature */ + get: operations['signature_api_manifest_signature_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/timeline': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Timeline */ + get: operations['timeline_api_timeline_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/manifest': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Manifest */ + get: operations['manifest_api_manifest_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { - schemas: { - /** AuthorStat */ - AuthorStat: { - /** Name */ - name: string; - /** Commits */ - commits: number; - /** - * Hue - * @description Stable 0-359 hue from the name hash; the display colour is built from it client-side - */ - hue: number; - }; - /** BranchListResponse */ - BranchListResponse: { - /** Branches */ - branches: string[]; - /** Default */ - default: string | null; - }; - /** BusynessThresholds */ - BusynessThresholds: { - /** Avg */ - avg: number; - /** Busy */ - busy: number; - }; - /** CacheClearResponse */ - CacheClearResponse: { - /** Deleted */ - deleted: number; - }; - /** - * CloneProgressEvent - * @description `clone-progress` — git source is being cloned; carries clone progress. - * - * A normal progress tick has `stage` + `percent`. A heartbeat during the - * silent promisor blob fetch instead carries `mb_on_disk` (and no percent), - * so the UI shows the working tree materializing rather than freezing. - */ - CloneProgressEvent: { - /** Label */ - label?: string; - /** - * Stage - * @enum {string} - */ - stage?: "receiving" | "resolving" | "counting" | "updating"; - /** Percent */ - percent?: number; - /** Mb On Disk */ - mb_on_disk?: number; - }; - /** CommitDateRange */ - CommitDateRange: { - /** - * Oldest - * @description Oldest commit date (YYYY-MM-DD), or null when the repo has no commits - */ - oldest: string | null; - /** - * Newest - * @description Newest commit date (YYYY-MM-DD), or null when the repo has no commits - */ - newest: string | null; - }; - /** CommitDetailResponse */ - CommitDetailResponse: { - /** Sha */ - sha: string; - /** Authors */ - authors: string[]; - /** Date */ - date: string; - /** Subject */ - subject: string; - /** Body */ - body: string; - }; - /** CommitEntry */ - CommitEntry: { - /** - * Date - * @description ISO-8601 UTC, e.g. 2026-07-25T14:03:21Z - */ - date: string; - /** Files */ - files: number; - /** Sha */ - sha: string; - /** Authors */ - authors: string[]; - /** Subject */ - subject: string; - /** Same Day Total */ - same_day_total: number; - }; - /** CommitLeader */ - CommitLeader: { - /** Sha */ - sha: string; - /** Files */ - files: number; - }; - /** - * CompleteManifestEvent - * @description `manifest-complete` — a manifest with real, fully-populated metadata (a - * fresh scan's final pass, or a warm cache hit). - */ - CompleteManifestEvent: { - manifest: components["schemas"]["Manifest"]; - }; - /** ConfigResponse */ - ConfigResponse: { - /** Allowlocalrepos */ - allowLocalRepos: boolean; - }; - /** DateRangeMs */ - DateRangeMs: { - /** Mincreated */ - minCreated: number; - /** Maxcreated */ - maxCreated: number; - /** Minmodified */ - minModified: number; - /** Maxmodified */ - maxModified: number; - }; - /** DateRanges */ - DateRanges: { - /** - * Mincreated - * @description Earliest resolved create date (ISO), or null for an empty tree - */ - minCreated: string | null; - /** - * Maxcreated - * @description Latest resolved create date (ISO), or null for an empty tree - */ - maxCreated: string | null; - /** - * Minmodified - * @description Earliest resolved modify date (ISO), or null for an empty tree - */ - minModified: string | null; - /** - * Maxmodified - * @description Latest resolved modify date (ISO), or null for an empty tree - */ - maxModified: string | null; - }; - /** DayLeader */ - DayLeader: { - /** Date */ - date: string; - /** Count */ - count: number; - }; - /** DirLeader */ - DirLeader: { - /** Path */ - path: string; - /** Depth */ - depth: number; - /** Children */ - children: number; - /** Descendants */ - descendants: number; - }; - /** DirNode */ - DirNode: { - /** Name */ - name: string; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "directory"; - /** Path */ - path: string; - /** Fullpath */ - fullPath: string; - /** Children */ - children: (components["schemas"]["FileNode"] | components["schemas"]["DirNode"])[]; - /** Children Count */ - children_count: number; - /** Children File Count */ - children_file_count: number; - /** Children Dir Count */ - children_dir_count: number; - /** Descendants Count */ - descendants_count: number; - /** Descendants File Count */ - descendants_file_count: number; - /** Descendants Dir Count */ - descendants_dir_count: number; - /** Descendants Size */ - descendants_size: number; - /** Descendants Created Min */ - descendants_created_min: string | null; - /** Descendants Modified Max */ - descendants_modified_max: string | null; - /** Descendants Ext Breakdown */ - descendants_ext_breakdown: components["schemas"]["ExtBreakdownEntry"][]; - }; - /** - * ErrorEvent - * @description `error` — a failure after the stream began; carries the message. - */ - ErrorEvent: { - /** Error */ - error: string; - }; - /** ExtBreakdownEntry */ - ExtBreakdownEntry: { - /** Ext */ - ext: string | null; - /** Count */ - count: number; - /** Size */ - size: number; - }; - /** FileLeader */ - FileLeader: { - /** Path */ - path: string; - /** Lines */ - lines: number; - /** Bytes */ - bytes: number; - /** Created */ - created: string; - /** Modified */ - modified: string; - /** Media Width */ - media_width?: number; - /** Media Height */ - media_height?: number; - }; - /** FileNode */ - FileNode: { - /** Name */ - name: string; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "file"; - /** Path */ - path: string; - /** Fullpath */ - fullPath: string; - /** Extension */ - extension: string; - /** Size */ - size: number; - /** Lines */ - lines: number; - /** Binary */ - binary: boolean; - /** - * Dirty - * @description Working-tree differs from HEAD for this tracked file (staged or unstaged). Always False for clean/remote repos. - */ - dirty: boolean; - /** - * Created - * @description ISO create date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise - */ - created: string; - /** - * Modified - * @description ISO modify date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise. When dirty is true, this is always the working-tree filesystem date, regardless of git history - */ - modified: string; - /** - * Mediakind - * @description Media classification by extension (single source for the frontend); null for non-media files - */ - mediaKind?: ("image" | "video") | null; - /** Media Width */ - media_width?: number; - /** Media Height */ - media_height?: number; - /** Binarytype */ - binaryType?: string; - }; - /** - * FingerprintEntry - * @description One binary file's byte-pattern fingerprint in a POST /api/fingerprints - * batch response: a base64-encoded grayscale PNG (image/png implied), keyed - * by request path. Computed server-side from the file's head — raw binary - * bytes never ship to the client. - */ - FingerprintEntry: { - /** B64 */ - b64: string; - }; - /** HTTPValidationError */ - HTTPValidationError: { - /** Detail */ - detail?: components["schemas"]["ValidationError"][]; - }; - /** HealthResponse */ - HealthResponse: { - /** Ok */ - ok: boolean; - }; - /** - * ImageBatchEntry - * @description One image in a POST /api/images batch response: its content-type and - * base64-encoded bytes, keyed by request path in the response map. - */ - ImageBatchEntry: { - /** Mime */ - mime: string; - /** B64 */ - b64: string; - }; - /** Manifest */ - Manifest: { - /** Root */ - root: string; - /** Scanned At */ - scanned_at: string; - /** Content Signature */ - content_signature: string; - /** Structure Signature */ - structure_signature: string; - /** Layout Signature */ - layout_signature: string; - tree: components["schemas"]["DirNode"]; - repo: components["schemas"]["RepoInfo"]; - /** Commits */ - commits: components["schemas"]["CommitEntry"][]; - busyness: components["schemas"]["BusynessThresholds"]; - dateRanges: components["schemas"]["DateRanges"]; - stats: components["schemas"]["RepoStats"]; - /** - * Readmepath - * @description Absolute path of the root README, or null if there isn't one - */ - readmePath: string | null; - /** - * Readmemodified - * @description That README's mtime, for cache-busting the fetch - */ - readmeModified: string | null; - }; - /** - * PartialManifestEvent - * @description `manifest-partial` — a manifest with the real tree structure but - * placeholder file metadata, sent so the UI can paint the city before - * per-file metadata is resolved. - */ - PartialManifestEvent: { - manifest: components["schemas"]["Manifest"]; - }; - /** PathBatchRequest */ - PathBatchRequest: { - /** Paths */ - paths: string[]; - /** Shas */ - shas?: { - [key: string]: string; - } | null; - }; - /** RangeStat */ - RangeStat: { - /** Min */ - min: number; - /** Max */ - max: number; - }; - /** RepoInfo */ - RepoInfo: { - /** Branch */ - branch: string | null; - /** Remote Url */ - remote_url: string | null; - /** Head Sha */ - head_sha: string | null; - /** Head Subject */ - head_subject: string | null; - /** Dirty */ - dirty: boolean; - }; - /** RepoStats */ - RepoStats: { - lineCountRange: components["schemas"]["RangeStat"]; - byteSizeRange: components["schemas"]["RangeStat"]; - oldestCreatedFile: components["schemas"]["FileLeader"] | null; - newestCreatedFile: components["schemas"]["FileLeader"] | null; - newestModifiedFile: components["schemas"]["FileLeader"] | null; - oldestModifiedFile: components["schemas"]["FileLeader"] | null; - maxLinesFile: components["schemas"]["FileLeader"] | null; - minLinesFile: components["schemas"]["FileLeader"] | null; - maxBytesFile: components["schemas"]["FileLeader"] | null; - minBytesFile: components["schemas"]["FileLeader"] | null; - maxMediaBytesFile: components["schemas"]["FileLeader"] | null; - minMediaBytesFile: components["schemas"]["FileLeader"] | null; - maxMediaPixelsFile: components["schemas"]["FileLeader"] | null; - minMediaPixelsFile: components["schemas"]["FileLeader"] | null; - maxBinaryBytesFile: components["schemas"]["FileLeader"] | null; - minBinaryBytesFile: components["schemas"]["FileLeader"] | null; - /** Mediacount */ - mediaCount: number; - /** Binarycount */ - binaryCount: number; - /** Totallines */ - totalLines: number; - /** Dirtyfilecount */ - dirtyFileCount: number; - /** Codebytes */ - codeBytes: number; - maxDepthDir: components["schemas"]["DirLeader"] | null; - maxChildrenDir: components["schemas"]["DirLeader"] | null; - minChildrenDir: components["schemas"]["DirLeader"] | null; - maxFilesPerCommit: components["schemas"]["CommitLeader"] | null; - minFilesPerCommit: components["schemas"]["CommitLeader"] | null; - commitDates: components["schemas"]["CommitDateRange"]; - maxCommitsPerDay: components["schemas"]["DayLeader"] | null; - /** Maxcommitstreakdays */ - maxCommitStreakDays: number; - /** Authors */ - authors: components["schemas"]["AuthorStat"][]; - }; - /** - * ScanProgressEvent - * @description `scan-progress` — the working tree is being walked; carries the - * heartbeat files-scanned count. - */ - ScanProgressEvent: { - /** Label */ - label?: string; - /** Files Scanned */ - files_scanned?: number; - }; - /** SignatureResponse */ - SignatureResponse: { - /** Root */ - root: string; - /** Scanned At */ - scanned_at: string; - /** Content Signature */ - content_signature: string; - }; - /** - * TimelineBundle - * @description Wire schema for the scrub bundle; mirrors manifest_types.TimelineBundle. - */ - TimelineBundle: { - /** Commits */ - commits: components["schemas"]["CommitEntry"][]; - unionManifest: components["schemas"]["Manifest"]; - /** Deltas */ - deltas: components["schemas"]["TimelineDelta"][]; - /** Bloblines */ - blobLines: { - [key: string]: number; - }; - /** Blobsizes */ - blobSizes: { - [key: string]: number; - }; - /** Commitlineranges */ - commitLineRanges: components["schemas"]["RangeStat"][]; - /** Commitdateranges */ - commitDateRanges: components["schemas"]["DateRangeMs"][]; - /** Note */ - note: string | null; - }; - /** TimelineChange */ - TimelineChange: { - /** Path */ - path: string; - /** - * Sha - * @description New blob sha, or null when deleted - */ - sha: string | null; - }; - /** - * TimelineCompleteEvent - * @description `timeline-complete` — the full replay bundle (fresh build or warm - * cache hit). - */ - TimelineCompleteEvent: { - bundle: components["schemas"]["TimelineBundle"]; - }; - /** TimelineDelta */ - TimelineDelta: { - /** Sha */ - sha: string; - /** Changes */ - changes: components["schemas"]["TimelineChange"][]; - }; - /** - * TimelineProgressEvent - * @description `timeline-progress` — the history walk, blob-table resolution, or (for a - * blobless remote clone) the up-front blob backfill is in progress. The - * `fetch` stage carries `percent`; `history` carries `commits`; `blobs` - * carries `blobsDone`/`blobsTotal` (the total is known up front from the batch - * blob lookup, so that stage reports two ticks, not a live stream). - */ - TimelineProgressEvent: { - /** - * Stage - * @enum {string} - */ - stage: "fetch" | "history" | "blobs"; - /** Percent */ - percent?: number; - /** Commits */ - commits?: number; - /** Blobsdone */ - blobsDone?: number; - /** Blobstotal */ - blobsTotal?: number; - /** Label */ - label?: string; - }; - /** ValidationError */ - ValidationError: { - /** Location */ - loc: (string | number)[]; - /** Message */ - msg: string; - /** Error Type */ - type: string; - /** Input */ - input?: unknown; - /** Context */ - ctx?: Record; - }; + schemas: { + /** AuthorStat */ + AuthorStat: { + /** Name */ + name: string; + /** Commits */ + commits: number; + /** + * Hue + * @description Stable 0-359 hue from the name hash; the display colour is built from it client-side + */ + hue: number; + }; + /** BranchListResponse */ + BranchListResponse: { + /** Branches */ + branches: string[]; + /** Default */ + default: string | null; + }; + /** BusynessThresholds */ + BusynessThresholds: { + /** Avg */ + avg: number; + /** Busy */ + busy: number; + }; + /** + * CloneProgressEvent + * @description `clone-progress` — git source is being cloned; carries clone progress. + * + * A normal progress tick has `stage` + `percent`. A heartbeat during the + * silent promisor blob fetch instead carries `mb_on_disk` (and no percent), + * so the UI shows the working tree materializing rather than freezing. + */ + CloneProgressEvent: { + /** Label */ + label?: string; + /** + * Stage + * @enum {string} + */ + stage?: 'receiving' | 'resolving' | 'counting' | 'updating'; + /** Percent */ + percent?: number; + /** Mb On Disk */ + mb_on_disk?: number; + }; + /** CommitDateRange */ + CommitDateRange: { + /** + * Oldest + * @description Oldest commit date (YYYY-MM-DD), or null when the repo has no commits + */ + oldest: string | null; + /** + * Newest + * @description Newest commit date (YYYY-MM-DD), or null when the repo has no commits + */ + newest: string | null; + }; + /** CommitDetailResponse */ + CommitDetailResponse: { + /** Sha */ + sha: string; + /** Authors */ + authors: string[]; + /** Date */ + date: string; + /** Subject */ + subject: string; + /** Body */ + body: string; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; + /** CommitEntry */ + CommitEntry: { + /** + * Date + * @description ISO-8601 UTC, e.g. 2026-07-25T14:03:21Z + */ + date: string; + /** Files */ + files: number; + /** Sha */ + sha: string; + /** Authors */ + authors: string[]; + /** Subject */ + subject: string; + /** Same Day Total */ + same_day_total: number; + }; + /** CommitLeader */ + CommitLeader: { + /** Sha */ + sha: string; + /** Files */ + files: number; + }; + /** + * CompleteManifestEvent + * @description `manifest-complete` — a manifest with real, fully-populated metadata (a + * fresh scan's final pass, or a warm cache hit). + */ + CompleteManifestEvent: { + manifest: components['schemas']['Manifest']; + }; + /** ConfigResponse */ + ConfigResponse: { + /** Allowlocalrepos */ + allowLocalRepos: boolean; + }; + /** DateRangeMs */ + DateRangeMs: { + /** Mincreated */ + minCreated: number; + /** Maxcreated */ + maxCreated: number; + /** Minmodified */ + minModified: number; + /** Maxmodified */ + maxModified: number; + }; + /** DateRanges */ + DateRanges: { + /** + * Mincreated + * @description Earliest resolved create date (ISO), or null for an empty tree + */ + minCreated: string | null; + /** + * Maxcreated + * @description Latest resolved create date (ISO), or null for an empty tree + */ + maxCreated: string | null; + /** + * Minmodified + * @description Earliest resolved modify date (ISO), or null for an empty tree + */ + minModified: string | null; + /** + * Maxmodified + * @description Latest resolved modify date (ISO), or null for an empty tree + */ + maxModified: string | null; + }; + /** DayLeader */ + DayLeader: { + /** Date */ + date: string; + /** Count */ + count: number; + }; + /** DirLeader */ + DirLeader: { + /** Path */ + path: string; + /** Depth */ + depth: number; + /** Children */ + children: number; + /** Descendants */ + descendants: number; + }; + /** DirNode */ + DirNode: { + /** Name */ + name: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'directory'; + /** Path */ + path: string; + /** Fullpath */ + fullPath: string; + /** Children */ + children: (components['schemas']['FileNode'] | components['schemas']['DirNode'])[]; + /** Children Count */ + children_count: number; + /** Children File Count */ + children_file_count: number; + /** Children Dir Count */ + children_dir_count: number; + /** Descendants Count */ + descendants_count: number; + /** Descendants File Count */ + descendants_file_count: number; + /** Descendants Dir Count */ + descendants_dir_count: number; + /** Descendants Size */ + descendants_size: number; + /** Descendants Created Min */ + descendants_created_min: string | null; + /** Descendants Modified Max */ + descendants_modified_max: string | null; + /** Descendants Ext Breakdown */ + descendants_ext_breakdown: components['schemas']['ExtBreakdownEntry'][]; + }; + /** + * ErrorEvent + * @description `error` — a failure after the stream began; carries the message. + */ + ErrorEvent: { + /** Error */ + error: string; + }; + /** ExtBreakdownEntry */ + ExtBreakdownEntry: { + /** Ext */ + ext: string | null; + /** Count */ + count: number; + /** Size */ + size: number; + }; + /** FileLeader */ + FileLeader: { + /** Path */ + path: string; + /** Lines */ + lines: number; + /** Bytes */ + bytes: number; + /** Created */ + created: string; + /** Modified */ + modified: string; + /** Media Width */ + media_width?: number; + /** Media Height */ + media_height?: number; + }; + /** FileNode */ + FileNode: { + /** Name */ + name: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: 'file'; + /** Path */ + path: string; + /** Fullpath */ + fullPath: string; + /** Extension */ + extension: string; + /** Size */ + size: number; + /** Lines */ + lines: number; + /** Binary */ + binary: boolean; + /** + * Dirty + * @description Working-tree differs from HEAD for this tracked file (staged or unstaged). Always False for clean/remote repos. + */ + dirty: boolean; + /** + * Created + * @description ISO create date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise + */ + created: string; + /** + * Modified + * @description ISO modify date (UTC, Z-suffixed), resolved server-side: git history date when the file has one, filesystem date otherwise. When dirty is true, this is always the working-tree filesystem date, regardless of git history + */ + modified: string; + /** + * Mediakind + * @description Media classification by extension (single source for the frontend); null for non-media files + */ + mediaKind?: ('image' | 'video') | null; + /** Media Width */ + media_width?: number; + /** Media Height */ + media_height?: number; + /** Binarytype */ + binaryType?: string; + }; + /** + * FingerprintEntry + * @description One binary file's byte-pattern fingerprint in a POST /api/fingerprints + * batch response: a base64-encoded grayscale PNG (image/png implied), keyed + * by request path. Computed server-side from the file's head — raw binary + * bytes never ship to the client. + */ + FingerprintEntry: { + /** B64 */ + b64: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components['schemas']['ValidationError'][]; + }; + /** HealthResponse */ + HealthResponse: { + /** Ok */ + ok: boolean; + }; + /** + * ImageBatchEntry + * @description One image in a POST /api/images batch response: its content-type and + * base64-encoded bytes, keyed by request path in the response map. + */ + ImageBatchEntry: { + /** Mime */ + mime: string; + /** B64 */ + b64: string; + }; + /** Manifest */ + Manifest: { + /** Root */ + root: string; + /** Scanned At */ + scanned_at: string; + /** Content Signature */ + content_signature: string; + /** Structure Signature */ + structure_signature: string; + /** Layout Signature */ + layout_signature: string; + tree: components['schemas']['DirNode']; + repo: components['schemas']['RepoInfo']; + /** Commits */ + commits: components['schemas']['CommitEntry'][]; + busyness: components['schemas']['BusynessThresholds']; + dateRanges: components['schemas']['DateRanges']; + stats: components['schemas']['RepoStats']; + /** + * Readmepath + * @description Absolute path of the root README, or null if there isn't one + */ + readmePath: string | null; + /** + * Readmemodified + * @description That README's mtime, for cache-busting the fetch + */ + readmeModified: string | null; + }; + /** + * PartialManifestEvent + * @description `manifest-partial` — a manifest with the real tree structure but + * placeholder file metadata, sent so the UI can paint the city before + * per-file metadata is resolved. + */ + PartialManifestEvent: { + manifest: components['schemas']['Manifest']; + }; + /** PathBatchRequest */ + PathBatchRequest: { + /** Paths */ + paths: string[]; + /** Shas */ + shas?: { + [key: string]: string; + } | null; + }; + /** RangeStat */ + RangeStat: { + /** Min */ + min: number; + /** Max */ + max: number; + }; + /** RepoInfo */ + RepoInfo: { + /** Branch */ + branch: string | null; + /** Remote Url */ + remote_url: string | null; + /** Head Sha */ + head_sha: string | null; + /** Head Subject */ + head_subject: string | null; + /** Dirty */ + dirty: boolean; + }; + /** RepoStats */ + RepoStats: { + lineCountRange: components['schemas']['RangeStat']; + byteSizeRange: components['schemas']['RangeStat']; + oldestCreatedFile: components['schemas']['FileLeader'] | null; + newestCreatedFile: components['schemas']['FileLeader'] | null; + newestModifiedFile: components['schemas']['FileLeader'] | null; + oldestModifiedFile: components['schemas']['FileLeader'] | null; + maxLinesFile: components['schemas']['FileLeader'] | null; + minLinesFile: components['schemas']['FileLeader'] | null; + maxBytesFile: components['schemas']['FileLeader'] | null; + minBytesFile: components['schemas']['FileLeader'] | null; + maxMediaBytesFile: components['schemas']['FileLeader'] | null; + minMediaBytesFile: components['schemas']['FileLeader'] | null; + maxMediaPixelsFile: components['schemas']['FileLeader'] | null; + minMediaPixelsFile: components['schemas']['FileLeader'] | null; + maxBinaryBytesFile: components['schemas']['FileLeader'] | null; + minBinaryBytesFile: components['schemas']['FileLeader'] | null; + /** Mediacount */ + mediaCount: number; + /** Binarycount */ + binaryCount: number; + /** Totallines */ + totalLines: number; + /** Dirtyfilecount */ + dirtyFileCount: number; + /** Codebytes */ + codeBytes: number; + maxDepthDir: components['schemas']['DirLeader'] | null; + maxChildrenDir: components['schemas']['DirLeader'] | null; + minChildrenDir: components['schemas']['DirLeader'] | null; + maxFilesPerCommit: components['schemas']['CommitLeader'] | null; + minFilesPerCommit: components['schemas']['CommitLeader'] | null; + commitDates: components['schemas']['CommitDateRange']; + maxCommitsPerDay: components['schemas']['DayLeader'] | null; + /** Maxcommitstreakdays */ + maxCommitStreakDays: number; + /** Authors */ + authors: components['schemas']['AuthorStat'][]; + }; + /** + * ScanProgressEvent + * @description `scan-progress` — the working tree is being walked; carries the + * heartbeat files-scanned count. + */ + ScanProgressEvent: { + /** Label */ + label?: string; + /** Files Scanned */ + files_scanned?: number; + }; + /** SignatureResponse */ + SignatureResponse: { + /** Root */ + root: string; + /** Scanned At */ + scanned_at: string; + /** Content Signature */ + content_signature: string; + }; + /** + * TimelineBundle + * @description Wire schema for the scrub bundle; mirrors manifest_types.TimelineBundle. + */ + TimelineBundle: { + /** Commits */ + commits: components['schemas']['CommitEntry'][]; + unionManifest: components['schemas']['Manifest']; + /** Deltas */ + deltas: components['schemas']['TimelineDelta'][]; + /** Bloblines */ + blobLines: { + [key: string]: number; + }; + /** Blobsizes */ + blobSizes: { + [key: string]: number; + }; + /** Commitlineranges */ + commitLineRanges: components['schemas']['RangeStat'][]; + /** Commitdateranges */ + commitDateRanges: components['schemas']['DateRangeMs'][]; + /** Note */ + note: string | null; + }; + /** TimelineChange */ + TimelineChange: { + /** Path */ + path: string; + /** + * Sha + * @description New blob sha, or null when deleted + */ + sha: string | null; + }; + /** + * TimelineCompleteEvent + * @description `timeline-complete` — the full replay bundle (fresh build or warm + * cache hit). + */ + TimelineCompleteEvent: { + bundle: components['schemas']['TimelineBundle']; + }; + /** TimelineDelta */ + TimelineDelta: { + /** Sha */ + sha: string; + /** Changes */ + changes: components['schemas']['TimelineChange'][]; + }; + /** + * TimelineProgressEvent + * @description `timeline-progress` — the history walk, blob-table resolution, or (for a + * blobless remote clone) the up-front blob backfill is in progress. The + * `fetch` stage carries `percent`; `history` carries `commits`; `blobs` + * carries `blobsDone`/`blobsTotal` (the total is known up front from the batch + * blob lookup, so that stage reports two ticks, not a live stream). + */ + TimelineProgressEvent: { + /** + * Stage + * @enum {string} + */ + stage: 'fetch' | 'history' | 'blobs'; + /** Percent */ + percent?: number; + /** Commits */ + commits?: number; + /** Blobsdone */ + blobsDone?: number; + /** Blobstotal */ + blobsTotal?: number; + /** Label */ + label?: string; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; } export type $defs = Record; export interface operations { - health_api_health_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HealthResponse"]; - }; - }; - }; + health_api_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - config_api_config_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ConfigResponse"]; - }; - }; + content: { + 'application/json': components['schemas']['HealthResponse']; }; + }; }; - get_file_api_file_get: { - parameters: { - query: { - /** @description Absolute path inside a scanned root */ - path: string; - /** @description Blob sha to read instead of the working tree */ - sha?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + config_api_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - get_images_api_images_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; }; - requestBody: { - content: { - "application/json": components["schemas"]["PathBatchRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: components["schemas"]["ImageBatchEntry"]; - }; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; + content: { + 'application/json': components['schemas']['ConfigResponse']; }; + }; }; - get_fingerprints_api_fingerprints_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["PathBatchRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: components["schemas"]["FingerprintEntry"]; - }; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + get_file_api_file_get: { + parameters: { + query: { + /** @description Absolute path inside a scanned root */ + path: string; + /** @description Blob sha to read instead of the working tree */ + sha?: string | null; + }; + header?: never; + path?: never; + cookie?: never; }; - get_commit_api_commit_get: { - parameters: { - query: { - sha: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CommitDetailResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; }; - get_branches_api_branches_get: { - parameters: { - query: { - src: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BranchListResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + get_images_api_images_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - signature_api_manifest_signature_get: { - parameters: { - query: { - src: string; - branch?: string | null; - no_cache?: boolean; - exclude?: string[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SignatureResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + requestBody: { + content: { + 'application/json': components['schemas']['PathBatchRequest']; + }; }; - timeline_api_timeline_get: { - parameters: { - query: { - src: string; - branch?: string | null; - no_cache?: boolean; - exclude?: string[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `timeline-progress` (TimelineProgressEvent, one or more while the history walk / blob resolution run), `timeline-complete` (TimelineCompleteEvent, the full bundle), `error` (ErrorEvent). A warm cache hit emits only `timeline-complete`, no progress. The client closes the connection on `timeline-complete`/`error`. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TimelineProgressEvent"] | components["schemas"]["TimelineCompleteEvent"] | components["schemas"]["ErrorEvent"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + [key: string]: components['schemas']['ImageBatchEntry']; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; }; - clear_cache_api_manifest_cache_delete: { - parameters: { - query: { - src: string; - branch?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CacheClearResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + }; + get_fingerprints_api_fingerprints_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - manifest_api_manifest_get: { - parameters: { - query?: { - src?: string; - branch?: string | null; - no_cache?: boolean; - exclude?: string[]; - ref?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `clone-progress` (CloneProgressEvent), `scan-progress` (ScanProgressEvent), `manifest-partial` (PartialManifestEvent), `manifest-complete` (CompleteManifestEvent), `error` (ErrorEvent). The client closes the connection on `manifest-complete`/`error`. When `ref` is set, the manifest is reconstructed as of that commit instead of the working tree (a remote source still emits `clone-progress` if it isn't cloned yet, but never `scan-progress`/`manifest-partial` for the reconstruction itself — the city is already drawn, so a skeleton would flash placeholders). */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CloneProgressEvent"] | components["schemas"]["ScanProgressEvent"] | components["schemas"]["PartialManifestEvent"] | components["schemas"]["CompleteManifestEvent"] | components["schemas"]["ErrorEvent"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; + requestBody: { + content: { + 'application/json': components['schemas']['PathBatchRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + [key: string]: components['schemas']['FingerprintEntry']; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + get_commit_api_commit_get: { + parameters: { + query: { + sha: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CommitDetailResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + get_branches_api_branches_get: { + parameters: { + query: { + src: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BranchListResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + signature_api_manifest_signature_get: { + parameters: { + query: { + src: string; + branch?: string | null; + no_cache?: boolean; + exclude?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SignatureResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + timeline_api_timeline_get: { + parameters: { + query: { + src: string; + branch?: string | null; + no_cache?: boolean; + exclude?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `timeline-progress` (TimelineProgressEvent, one or more while the history walk / blob resolution run), `timeline-complete` (TimelineCompleteEvent, the full bundle), `error` (ErrorEvent). A warm cache hit emits only `timeline-complete`, no progress. The client closes the connection on `timeline-complete`/`error`. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': + | components['schemas']['TimelineProgressEvent'] + | components['schemas']['TimelineCompleteEvent'] + | components['schemas']['ErrorEvent']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + manifest_api_manifest_get: { + parameters: { + query?: { + src?: string; + branch?: string | null; + no_cache?: boolean; + exclude?: string[]; + ref?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Server-Sent Events stream (`text/event-stream`). Named events and their JSON `data` payloads: `clone-progress` (CloneProgressEvent), `scan-progress` (ScanProgressEvent), `manifest-partial` (PartialManifestEvent), `manifest-complete` (CompleteManifestEvent), `error` (ErrorEvent). The client closes the connection on `manifest-complete`/`error`. When `ref` is set, the manifest is reconstructed as of that commit instead of the working tree (a remote source still emits `clone-progress` if it isn't cloned yet, but never `scan-progress`/`manifest-partial` for the reconstruction itself — the city is already drawn, so a skeleton would flash placeholders). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': + | components['schemas']['CloneProgressEvent'] + | components['schemas']['ScanProgressEvent'] + | components['schemas']['PartialManifestEvent'] + | components['schemas']['CompleteManifestEvent'] + | components['schemas']['ErrorEvent']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; }; + }; } diff --git a/app/tests/components/RecentsList.test.tsx b/app/tests/components/RecentsList.test.tsx index 630e358c..b783750f 100644 --- a/app/tests/components/RecentsList.test.tsx +++ b/app/tests/components/RecentsList.test.tsx @@ -8,7 +8,6 @@ import { SERVER_CONFIG } from '@/state/stores/serverConfig'; import { setManifest } from '@/state/stores/manifest'; import { EMPTY_MANIFEST } from '@/constants/manifest'; import { RecentsList } from '@/components/RecentsList/RecentsList'; -import * as manifestApi from '@/api/manifest'; import type { Manifest } from '@/types'; import { flush } from '../_helpers/preact'; @@ -77,8 +76,7 @@ describe('RecentsList', () => { expect(container.querySelector('.recent-row--active')).toBeTruthy(); }); - it('remove is non-destructive: forgets the entry, does not touch the cache', async () => { - const spy = vi.spyOn(manifestApi, 'clearManifestCache'); + it('remove forgets the entry behind a confirm step', async () => { render( {}} />, container); await flush(); @@ -100,6 +98,5 @@ describe('RecentsList', () => { await flush(); expect(RECENTS.value.find((r) => r.label === 'o/alpha')).toBeUndefined(); - expect(spy).not.toHaveBeenCalled(); }); }); From fb8c1c5907c439bdce3f8fb8eb09f2649ef9f3fe Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 26 Jul 2026 14:07:00 -0400 Subject: [PATCH 05/37] Delete orphaned CSS and correct comments that describe code that moved (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed rules nothing applies: .file-path-ellipsis / .file-path-segment / .file-path-sep (residue from stripping PathBreadcrumbs), .street-label (street labels render on canvas, and the overlay fallback is gone), and the .timeline-notice arm of the cc-showcase hide list. Kept the .text-*, .dot--* and .row--* families: those are documented vocabulary in shared styles/ partials, not leftovers from a removed feature. The comments were the worse half. Each described a relationship that no longer holds, which is actively misleading when tracing styling: - TreePane.css claimed selection lives in .row--selected; the tree keys off .tree-item.tree-selected. - InfoPane.css claimed .info-markdown code takes its shell from .code-inline; it is a bare element selector that redeclares everything, because marked emits with no class. - overlaps.ts cited tests/city/layout.test.ts, which does not exist. - manifest_types.py said to hand-sync with app/types/manifest.ts — wrong path, and the TS side is generated from the OpenAPI schema, so no hand-sync exists. - stats.py's _author_hue narrated a completed migration. Also drops `export` from eight values used only inside their own module, so the compiler will flag them if they ever do become dead. --- api/services/manifest_types.py | 9 +++--- api/services/stats.py | 4 +-- .../city/components/buildings/facadePanels.ts | 4 +-- app/src/city/components/streets/streets.ts | 4 +-- app/src/city/layout/algorithm.ts | 2 +- app/src/city/layout/overlaps.ts | 4 +-- app/src/city/timeline/replay.ts | 2 +- app/src/city/utils/color/colors.ts | 4 +-- .../PathBreadcrumbs/PathBreadcrumbs.css | 9 ++---- app/src/layout/App/App.css | 3 +- .../views/FilePreviewPane/FilePreviewPane.css | 29 ------------------- app/src/views/InfoPane/InfoPane.css | 4 +-- app/src/views/StreetPane/StreetPane.css | 13 --------- app/src/views/TreePane/TreePane.css | 8 ++--- 14 files changed, 27 insertions(+), 72 deletions(-) diff --git a/api/services/manifest_types.py b/api/services/manifest_types.py index cb5c058a..3638129f 100644 --- a/api/services/manifest_types.py +++ b/api/services/manifest_types.py @@ -13,10 +13,11 @@ leaf both import from. It imports only the pure models layer (never services), so it stays cycle-free for both. -Mirrors app/types/manifest.ts. Keep both in sync — the web app consumes -the JSON exactly as these TypedDicts describe it. Drift here is shape -drift in the wire format and will be caught by pyright on the Python -side and tsc on the TS side, but only within each language. +The frontend does not mirror this module by hand: `api/models/` produces the +OpenAPI schema, `just gen-types` turns that into +app/src/types/manifest.generated.ts, and app/src/types/manifest.ts derives from +it. So the pairing to keep honest is this module against `api/models/` — pyright +checks each side internally, but nothing checks the two against each other. """ from __future__ import annotations diff --git a/api/services/stats.py b/api/services/stats.py index 46ee83e6..37162202 100644 --- a/api/services/stats.py +++ b/api/services/stats.py @@ -94,8 +94,8 @@ def _longest_streak(dates: list[str]) -> int: def _author_hue(name: str) -> int: - """FNV-1a over the name's UTF-8 bytes, mod 360. Mirrors the 32-bit unsigned - arithmetic of the JS original so a name keeps the hue it already had.""" + """FNV-1a over the name's UTF-8 bytes, mod 360. The & 0xFFFFFFFF keeps the + hash in 32-bit unsigned range; widening it would repaint every author.""" h = 0x811C9DC5 for byte in name.encode("utf-8"): h ^= byte diff --git a/app/src/city/components/buildings/facadePanels.ts b/app/src/city/components/buildings/facadePanels.ts index 9c045557..5b40ddc3 100644 --- a/app/src/city/components/buildings/facadePanels.ts +++ b/app/src/city/components/buildings/facadePanels.ts @@ -707,7 +707,7 @@ function _releaseSlot(): void { * Videos: never batched (we only need the first frame, and