diff --git a/api/app.py b/api/app.py index fdb81068..0f19a734 100644 --- a/api/app.py +++ b/api/app.py @@ -16,7 +16,7 @@ from api.config import GZIP_MIN_BYTES from api.models.responses import ErrorResponse -from api.routers import commit, file, manifest, meta +from api.routers import branches, commit, file, manifest, meta from api.sse_compression import SSEGZipMiddleware from api.static import make_static_router @@ -84,6 +84,7 @@ def create_app(static_dir: Path | None = None) -> FastAPI: app.include_router(meta.router) app.include_router(file.router) app.include_router(commit.router) + app.include_router(branches.router) app.include_router(manifest.router) app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR)) return app diff --git a/api/models/responses.py b/api/models/responses.py index 6cd6c0ed..469ab2fc 100644 --- a/api/models/responses.py +++ b/api/models/responses.py @@ -41,3 +41,8 @@ class CommitDetailResponse(BaseModel): date: str # YYYY-MM-DD subject: str body: str + + +class BranchListResponse(BaseModel): + branches: list[str] + default: str | None diff --git a/api/routers/branches.py b/api/routers/branches.py new file mode 100644 index 00000000..37228bdd --- /dev/null +++ b/api/routers/branches.py @@ -0,0 +1,35 @@ +"""GET /api/branches?src= — remote branch list for the branch picker. + +Remote git URLs only: local sources scan the working tree in place and ignore +branch, so there is nothing to choose. Uses git ls-remote (no clone) and reuses +the clone error taxonomy so failures map to clean HTTP statuses.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query + +from api.models.responses import BranchListResponse +from api.services.clone import ( + CloneError, + HostUnreachableError, + RepoNotFoundError, + list_remote_branches, +) +from api.services.source import SourceKind, classify + +router = APIRouter(prefix="/api", tags=["branches"]) + + +@router.get("/branches", response_model=BranchListResponse) +def get_branches(src: str = Query(...)) -> BranchListResponse: + if classify(src) is not SourceKind.REMOTE: + raise HTTPException(400, "branches are only available for remote git URLs") + try: + branches, default = list_remote_branches(src) + except RepoNotFoundError as e: + raise HTTPException(404, str(e)) from e + except HostUnreachableError as e: + raise HTTPException(502, str(e)) from e + except CloneError as e: + raise HTTPException(400, str(e)) from e + return BranchListResponse(branches=branches, default=default) diff --git a/api/services/clone.py b/api/services/clone.py index 0f698885..023d137b 100644 --- a/api/services/clone.py +++ b/api/services/clone.py @@ -61,6 +61,7 @@ class HostUnreachableError(CloneError): "RepoNotFoundError", "clone_dir_for", "ensure_clone", + "list_remote_branches", "remove_clone", ] @@ -569,3 +570,50 @@ def remove_clone(url: str, branch: str | None) -> bool: 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. +_LS_REMOTE_TIMEOUT_S = 20 + + +def list_remote_branches(url: str) -> tuple[list[str], str | None]: + """Return ``(branch_names, default_branch)`` for a remote git URL via + ``git ls-remote --symref`` — no clone required (strictly less capability + than the clone the server already does for the same URL). + + ``default_branch`` is the name HEAD points at (None if the remote has no + symbolic HEAD, e.g. an empty repo). Raises the same clean CloneError + subclasses ``ensure_clone`` raises so routers reuse one error taxonomy.""" + try: + proc = subprocess.run( + ["git", "ls-remote", "--symref", "--", url], + capture_output=True, + text=True, + check=False, + env=_git_env(), + timeout=_LS_REMOTE_TIMEOUT_S, + ) + except FileNotFoundError as e: + raise CloneError("git executable not found on PATH") from e + except subprocess.TimeoutExpired as e: + raise HostUnreachableError("timed out reaching remote") from e + if proc.returncode != 0: + _maybe_raise_clean_clone_error(url, None, proc.stderr) + raise CloneError(f"git ls-remote failed: {proc.stderr.strip()}") + + default: str | None = None + branches: list[str] = [] + for line in proc.stdout.splitlines(): + # Symref line: "ref: refs/heads/main\tHEAD" + if line.startswith("ref: ") and line.endswith("\tHEAD"): + target = line[len("ref: ") :].split("\t", 1)[0] + if target.startswith("refs/heads/"): + default = target[len("refs/heads/") :] + continue + # Ref line: "\trefs/heads/" + parts = line.split("\t") + if len(parts) == 2 and parts[1].startswith("refs/heads/"): + branches.append(parts[1][len("refs/heads/") :]) + return branches, default diff --git a/api/tests/test_branches_service.py b/api/tests/test_branches_service.py new file mode 100644 index 00000000..ccbb93db --- /dev/null +++ b/api/tests/test_branches_service.py @@ -0,0 +1,24 @@ +"""Unit coverage for list_remote_branches (git ls-remote parsing).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from api.services.clone import RepoNotFoundError, list_remote_branches + + +def test_lists_heads_and_default(make_fake_remote, tmp_path: Path) -> None: + bare, _ = make_fake_remote(tmp_path) # bare repo, HEAD → main, has 'feature' too + branches, resolved_default = list_remote_branches(f"file://{bare}") + assert "main" in branches + assert "feature" in branches + assert resolved_default == "main" + # Only heads, no tags/HEAD pseudo-refs leak in. + assert all("/" not in b for b in branches) + + +def test_bogus_url_raises(tmp_path: Path) -> None: + with pytest.raises((RepoNotFoundError, Exception)): + list_remote_branches(f"file://{tmp_path / 'nope.git'}") diff --git a/api/tests/test_server_branches.py b/api/tests/test_server_branches.py new file mode 100644 index 00000000..604857d7 --- /dev/null +++ b/api/tests/test_server_branches.py @@ -0,0 +1,38 @@ +"""TestClient coverage for GET /api/branches.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from api.app import create_app + + +@pytest.fixture() +def client(tmp_path: Path) -> TestClient: + static = tmp_path / "static" + static.mkdir() + (static / "index.html").write_text("x") + return TestClient(create_app(static_dir=static)) + + +def test_branches_happy_path( + client: TestClient, make_fake_remote, tmp_path: Path +) -> None: + bare, _ = make_fake_remote(tmp_path) + r = client.get("/api/branches", params={"src": f"file://{bare}"}) + assert r.status_code == 200 + body = r.json() + assert "main" in body["branches"] + assert body["default"] == "main" + + +def test_branches_rejects_local_path(client: TestClient) -> None: + r = client.get("/api/branches", params={"src": "/tmp/some/local/path"}) + assert r.status_code == 400 + + +def test_branches_missing_src(client: TestClient) -> None: + assert client.get("/api/branches").status_code == 422 # FastAPI required-query diff --git a/app/src/api/branches.ts b/app/src/api/branches.ts new file mode 100644 index 00000000..67c6b8ab --- /dev/null +++ b/app/src/api/branches.ts @@ -0,0 +1,28 @@ +// api/branches.ts — Client for GET /api/branches. Fetches the remote branch +// list for a git URL so the picker can offer a valid-for-this-repo dropdown +// instead of a free-text field. Remote URLs only (local sources have no branch). + +import { URL_PARAMS } from '@/constants/urlParams'; + +export interface BranchList { + branches: string[]; + default: string | null; +} + +export async function fetchBranches(src: string): Promise { + const url = new URL('/api/branches', window.location.origin); + url.searchParams.set(URL_PARAMS.SRC, src); + const resp = await fetch(url.toString()); + if (!resp.ok) { + let message = `branch lookup failed (${resp.status})`; + try { + const body = (await resp.json()) as { detail?: string }; + if (body?.detail) message = body.detail; + } catch (_) { + /* non-JSON error body: keep the status-based message */ + } + throw new Error(message); + } + const body = (await resp.json()) as BranchList; + return { branches: body.branches ?? [], default: body.default ?? null }; +} diff --git a/app/src/api/manifest.ts b/app/src/api/manifest.ts index bdf6fb8b..c3d0b8c6 100644 --- a/app/src/api/manifest.ts +++ b/app/src/api/manifest.ts @@ -148,8 +148,9 @@ export type ScanProgressEvent = Extract< */ export function streamManifest( url: string, - EventSourceImpl: typeof EventSource = EventSource + opts: { signal?: AbortSignal; EventSourceImpl?: typeof EventSource } = {} ): AsyncIterable { + const EventSourceImpl = opts.EventSourceImpl ?? EventSource; return { [Symbol.asyncIterator](): AsyncIterator { const es = new EventSourceImpl(url); @@ -188,6 +189,14 @@ export function streamManifest( } }; + // Abort: close the stream cleanly (done, not error) so the consumer's + // for-await exits without a manifest. loadSource treats signal.aborted as + // a user cancel, not a failure. + if (opts.signal) { + if (opts.signal.aborted) finish(); + else opts.signal.addEventListener('abort', () => finish(), { once: true }); + } + // Parse an event's JSON data, ending the stream with an error (rather // than throwing into the swallowed event listener, which would leave the // iterator hanging forever) if the payload is malformed/truncated. diff --git a/app/src/components/LoadingOverlay/LoadingOverlay.tsx b/app/src/components/LoadingOverlay/LoadingOverlay.tsx index ba876c92..4974c24e 100644 --- a/app/src/components/LoadingOverlay/LoadingOverlay.tsx +++ b/app/src/components/LoadingOverlay/LoadingOverlay.tsx @@ -13,9 +13,16 @@ // visible advancement maps to a real NDJSON phase event (cloning, scanning, // skeleton, building) or the client-side decoration pass — no wall-clock timers. // The step vocabulary lives in constants/loadingSteps. +// +// Narrow role by design: a load driven from (every switch) +// renders its OWN inline progress and this overlay stays hidden — two +// full-viewport surfaces stacking on the same load would leave the user +// looking at this overlay's no-controls backdrop on top of the view's Cancel +// button. This overlay now only ever shows for a deep-link cold boot (no +// view open yet). import './LoadingOverlay.css'; -import { LOADING_OVERLAY } from '@/state/stores/ui'; +import { LOADING_OVERLAY, PROJECTS_VIEW } from '@/state/stores/ui'; import { PENDING_SOURCE_LABEL } from '@/state/stores/source'; import { SourceKind } from '@/utils/sources'; import { @@ -52,6 +59,7 @@ export function LoadingOverlay() { stepTails: lo.stepTails, }; if (!s.visible || !s.activeStep) return null; + if (PROJECTS_VIEW.value.visible) return null; const activeStep = s.activeStep; const activeIdx = LOADING_STEPS.indexOf(activeStep); diff --git a/app/src/components/PaneTabs/PaneTabs.css b/app/src/components/PaneTabs/PaneTabs.css index f4102072..857693d9 100644 --- a/app/src/components/PaneTabs/PaneTabs.css +++ b/app/src/components/PaneTabs/PaneTabs.css @@ -9,12 +9,6 @@ display: none; } -/* Modal context (source picker): the strip sits above form fields and needs a - gap below that the in-pane usage doesn't. */ -.pane-tabs--modal { - margin-bottom: 10px; -} - .pane-tab { display: inline-flex; align-items: center; diff --git a/app/src/constants/loadingSteps.ts b/app/src/constants/loadingSteps.ts index 42a6f085..456d78df 100644 --- a/app/src/constants/loadingSteps.ts +++ b/app/src/constants/loadingSteps.ts @@ -4,6 +4,9 @@ // sites and the data-step attribute stay self-documenting; the overlay and the // uiState setters both render/advance from these. +import { ScanPhase } from '@/api/manifest'; +import { SourceKind } from '@/utils/sources'; + export enum LoadingStep { Resolving = 'resolving', Cloning = 'cloning', @@ -46,3 +49,25 @@ export const LOADING_STEP_LABELS: Record = { [LoadingStep.Building]: 'Building city', [LoadingStep.Decorating]: 'Adding decorations', }; + +/** + * Map a scan-stream phase to the step it represents, given the source kind + * (local skips resolving/cloning — see LoadingOverlay's kind-based hiding). + * Shared by the loading-overlay reactions and ProjectsView's inline progress + * so the phase→step mapping has exactly one definition. + */ +export function stepForPhase(phase: ScanPhase | null, kind: SourceKind): LoadingStep { + switch (phase) { + case ScanPhase.CloneProgress: + return LoadingStep.Cloning; + case ScanPhase.ScanProgress: + return LoadingStep.Scanning; + case ScanPhase.PartialManifest: + return LoadingStep.Skeleton; + case ScanPhase.CompleteManifest: + return LoadingStep.Building; + default: + // phase === null: just-started, no stream event yet. + return kind === SourceKind.Local ? LoadingStep.Scanning : LoadingStep.Resolving; + } +} diff --git a/app/src/hooks/useManifestSource.ts b/app/src/hooks/useManifestSource.ts index f3d5703a..96256cdb 100644 --- a/app/src/hooks/useManifestSource.ts +++ b/app/src/hooks/useManifestSource.ts @@ -61,11 +61,12 @@ async function pumpManifestStream( onManifest: ( manifest: Manifest, phase: ScanPhase.PartialManifest | ScanPhase.CompleteManifest - ) => Promise | void + ) => Promise | void, + signal?: AbortSignal ): Promise { let lastManifest: Manifest | null = null; - for await (const event of streamManifest(url)) { + for await (const event of streamManifest(url, { signal })) { if (event.phase === ScanPhase.Error) throw new Error(event.error); if ('display_root' in event && event.display_root) { @@ -111,6 +112,10 @@ async function pumpManifestStream( // applyManifest already uses one layer down. 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: claim the generation → overlay → stream // skeleton+final into MANIFEST → on success commit the source (CURRENT_SOURCE + @@ -119,14 +124,21 @@ let loadGeneration = 0; // camera, App coordinates the picker off SOURCE_ERROR. (The live-update poll // below is a SEPARATE op — a background refresh of the current source that // shares only the MANIFEST sink, and yields to a foreground load via the gen.) -async function loadSource(payload: SourcePayload): Promise { +export async function loadSource(payload: SourcePayload): Promise { const myGen = ++loadGeneration; // claim authority; supersedes any in-flight load/poll + loadController?.abort(); // supersede any in-flight load + const controller = new AbortController(); + loadController = controller; const meta = { kind: srcKind(payload.src), label: labelFromSource(payload.src) ?? payload.src, branch: payload.branch, }; SCAN_PROGRESS.value = { ...meta, phase: null }; // show overlay immediately + // Snapshot the applied manifest so a cancel that lands after a skeleton was + // streamed into MANIFEST can roll it back — otherwise the canceled repo's + // partial geometry lingers under the unchanged CURRENT_SOURCE's header. + const prevManifest = MANIFEST.peek(); try { const url = manifestUrlFor({ @@ -136,10 +148,27 @@ async function loadSource(payload: SourcePayload): Promise { }); // Publish the skeleton as it streams (early structure, behind the overlay); // the final is published below, AFTER committing the source. - const manifest = await pumpManifestStream(url, meta, (m, phase) => { - if (phase === ScanPhase.PartialManifest && myGen === loadGeneration) setManifest(m); - }); - if (myGen !== loadGeneration) return; // a newer load superseded this one + 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; + // The user canceled after a skeleton arrived: streamManifest ends an aborted + // stream as done (not a throw), so pumpManifestStream RETURNED the partial + // manifest. Never commit it (CURRENT_SOURCE stays put) and roll MANIFEST back + // to the pre-load snapshot so the canceled repo's skeleton doesn't linger + // under the unchanged source. Same-source apply (CURRENT_SOURCE unchanged) → + // the render layer's camera-reframe reaction, keyed off CURRENT_SOURCE at + // apply-start, correctly does NOT reframe. + if (controller.signal.aborted) { + setManifest(prevManifest); + return; + } // Commit the source BEFORE publishing the final manifest. The render layer's // camera-reframe reaction keys off CURRENT_SOURCE captured at apply-START, so // the new key must be live for the FINAL apply (the one to frame on) and NOT @@ -148,6 +177,10 @@ async function loadSource(payload: SourcePayload): Promise { setManifest(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), prefill: { src: payload.src, branch: payload.branch }, @@ -158,10 +191,20 @@ async function loadSource(payload: SourcePayload): Promise { if (myGen === loadGeneration) { SCAN_PROGRESS.value = null; PENDING_SOURCE_LABEL.value = null; + if (loadController === controller) loadController = null; } } } +/** + * Abort the in-flight foreground load, if any. Exposed to the UI (a cancel + * button on the loading overlay) via the hook's return; the abort is treated + * as a clean user cancel, not a failure — see the `catch` branch above. + */ +export function cancelLoad(): void { + loadController?.abort(); +} + // ── Live-update poll loop ──────────────────────────────────────────── // Hard bounds for the user-set poll interval. 1s floor — the server does a real @@ -273,12 +316,18 @@ function setupLiveUpdates(): () => void { * Boot the manifest FETCH pipeline on mount: stream the initial manifest from * ?src into MANIFEST, fetch the server config, and start live updates. * Scene-free — the render layer (the City component) consumes MANIFEST and paints the - * scene. UI-free too: it never opens/closes the source picker; on a load failure + * scene. UI-free too: it never opens/closes ProjectsView; on a load failure * (boot or submit) it WRITES the canonical SOURCE_ERROR signal and App reacts to - * coordinate the picker. RETURNS the source-picker submit handler so App can pass - * it down to as a prop (no global register/invoke channel). + * coordinate ProjectsView. A user-initiated cancel (`cancelLoad`) aborts the + * in-flight stream but is NOT surfaced as a load failure — no SOURCE_ERROR write. + * RETURNS the submit handler and a cancel handler so App can pass + * them down to /the loading overlay as props (no global + * register/invoke channel). */ -export function useManifestSource(): (payload: SourcePayload) => void { +export function useManifestSource(): { + submitSource: (payload: SourcePayload) => void; + cancelLoad: () => void; +} { const submitSource = useCallback((payload: SourcePayload) => { void loadSource(payload); }, []); @@ -311,5 +360,5 @@ export function useManifestSource(): (payload: SourcePayload) => void { }; }, []); - return submitSource; + return { submitSource, cancelLoad }; } diff --git a/app/src/layout/App/App.tsx b/app/src/layout/App/App.tsx index aa8e2279..d448290b 100644 --- a/app/src/layout/App/App.tsx +++ b/app/src/layout/App/App.tsx @@ -10,8 +10,9 @@ // — self-subscribes to SCENE_HANDLE + picker // // — reads signals directly -// — reads SOURCE_PICKER + SERVER_CONFIG directly -// — reads LOADING_OVERLAY directly +// — reads PROJECTS_VIEW + SERVER_CONFIG directly; owns +// inline progress for a switch it initiates +// — reads LOADING_OVERLAY directly; deep-link boot only // — reads SHORTCUTS_OPEN directly // — reads DEBUG_OPEN directly; scene commands passed as props @@ -24,7 +25,7 @@ import { AppFooter } from '../AppFooter/AppFooter'; import { CenterPane } from '../CenterPane/CenterPane'; import { LeftSidebar } from '../LeftSidebar/LeftSidebar'; import { RightSidebar } from '../RightSidebar/RightSidebar'; -import { SourcePicker } from '@/views/SourcePicker/SourcePicker'; +import { ProjectsView } from '@/views/ProjectsView/ProjectsView'; import { ShortcutsModal } from '@/views/ShortcutsModal/ShortcutsModal'; import { DebugModal } from '@/views/DebugModal/DebugModal'; import { LoadingOverlay } from '@/components/LoadingOverlay/LoadingOverlay'; @@ -38,9 +39,9 @@ import { runStemDiagnostic, } from '@/state/stores/scene'; import { - openSourcePicker, - openSourcePickerForCurrentSource, - closeSourcePicker, + openProjectsView, + openProjectsViewForCurrentSource, + closeProjectsView, } from '@/state/stores/ui'; import { SOURCE_ERROR, CURRENT_SOURCE } from '@/state/stores/source'; import { MANIFEST } from '@/state/stores/manifest'; @@ -52,38 +53,43 @@ import { attachLoadingReactions } from '@/state/loadingReactions'; export function App() { useDocumentTitle(); - const submitSource = useManifestSource(); + const { submitSource, cancelLoad } = useManifestSource(); useEffect(() => attachLoadingReactions(), []); - // Reset the picker selection whenever a world commits, so panes derived from - // it (the right sidebar, tree highlight) don't carry a stale node into the - // new world. Keyed on CURRENT_SOURCE — live-reloads don't rewrite it, so an - // in-place refresh keeps your selection. + // A committed switch: CURRENT_SOURCE is written ONLY on a successful load, + // so reacting here both resets the picker selection (panes derived from it — + // the right sidebar, tree highlight — must not carry a stale node into the + // new world) and auto-closes the view to reveal the new city. One reaction, + // one concern: "a world committed". No-op on deep-link boot (view already + // closed) and on live-updates (they don't rewrite CURRENT_SOURCE). useSignalEffect(() => { - if (CURRENT_SOURCE.value) clearSelection(); + if (CURRENT_SOURCE.value) { + clearSelection(); + closeProjectsView(); + } }); - // App coordinates the source picker; the fetch hook only reports outcomes. - const dismissPicker = () => { - closeSourcePicker(); + // App coordinates the projects view; the fetch hook only reports outcomes. + const dismissView = () => { + closeProjectsView(); SOURCE_ERROR.value = null; }; // Cold boot with no ?src → prompt for a source (non-dismissible). useEffect(() => { if (!new URLSearchParams(window.location.search).has(URL_PARAMS.SRC)) { - openSourcePicker({ dismissible: false }); + openProjectsView({ dismissible: false }); } }, []); - // A source-load failure (boot or submit) reopens the picker. Dismissible only + // A source-load failure (boot or submit) reopens the view. Dismissible only // when a city is already loaded to fall back to (a failed FIRST pick stays // non-dismissible so the app can't end up blank). useSignalEffect(() => { const err = SOURCE_ERROR.value; if (!err) return; - openSourcePicker({ + openProjectsView({ dismissible: !isEmptyManifest(MANIFEST.peek()), prefill: err.prefill, error: err.error, @@ -94,7 +100,7 @@ export function App() { <> @@ -104,13 +110,7 @@ export function App() { - { - dismissPicker(); - submitSource(p); - }} - onClose={dismissPicker} - /> + submitSource(p)} onCancel={cancelLoad} onClose={dismissView} /> diff --git a/app/src/layout/AppHeader/AppHeader.tsx b/app/src/layout/AppHeader/AppHeader.tsx index cef64cd0..e28e5ee4 100644 --- a/app/src/layout/AppHeader/AppHeader.tsx +++ b/app/src/layout/AppHeader/AppHeader.tsx @@ -15,7 +15,7 @@ import { SCENE_HANDLE } from '@/state/stores/scene'; import { MANIFEST } from '@/state/stores/manifest'; import { ROOT_PATH } from '@/constants/manifest'; import { SOURCE_INFO } from '@/state/stores/source'; -import { openSourcePicker, openShortcuts, openDebug } from '@/state/stores/ui'; +import { openProjectsView, openShortcuts, openDebug } from '@/state/stores/ui'; import { isDebugMode } from '@/utils/debugMode'; import { NodeKind, type Manifest } from '@/types'; import { ResetViewButton } from '@/components/ResetViewButton'; @@ -86,7 +86,7 @@ export function AppHeader({ openSourcePicker({ dismissible: true }))} + onSwitchSource={onSwitchSource ?? (() => openProjectsView({ dismissible: true }))} /> diff --git a/app/src/state/loadingReactions.ts b/app/src/state/loadingReactions.ts index f44dab29..cbd4763c 100644 --- a/app/src/state/loadingReactions.ts +++ b/app/src/state/loadingReactions.ts @@ -13,7 +13,7 @@ import { setLoadingStepTail, } from '@/state/stores/ui'; import { ScanPhase } from '@/api/manifest'; -import { LoadingStep } from '@/constants/loadingSteps'; +import { LoadingStep, stepForPhase } from '@/constants/loadingSteps'; export function attachLoadingReactions(): () => void { let wasActive = false; @@ -30,8 +30,8 @@ export function attachLoadingReactions(): () => void { showLoadingOverlay({ kind: p.kind, label: p.label, branch: p.branch }); wasActive = true; } + setLoadingStep(stepForPhase(p.phase, p.kind)); if (p.phase === ScanPhase.CloneProgress) { - setLoadingStep(LoadingStep.Cloning); // A normal tick shows "{percent}% ({stage})"; a heartbeat during the // silent promisor blob fetch (no percent) shows the working tree growing // on disk so the step doesn't look frozen. @@ -43,7 +43,6 @@ export function attachLoadingReactions(): () => void { } setLoadingStepTail(LoadingStep.Cloning, tail); } else if (p.phase === ScanPhase.ScanProgress) { - setLoadingStep(LoadingStep.Scanning); setLoadingStepTail( LoadingStep.Scanning, p.filesScanned !== undefined ? `${p.filesScanned.toLocaleString()} files` : null @@ -52,9 +51,6 @@ export function attachLoadingReactions(): () => void { // Progress tails done. setLoadingStepTail(LoadingStep.Cloning, null); setLoadingStepTail(LoadingStep.Scanning, null); - setLoadingStep( - p.phase === ScanPhase.PartialManifest ? LoadingStep.Skeleton : LoadingStep.Building - ); } // 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/manifest.ts b/app/src/state/stores/manifest.ts index 4d38c3cb..9397fa3b 100644 --- a/app/src/state/stores/manifest.ts +++ b/app/src/state/stores/manifest.ts @@ -19,13 +19,17 @@ import { isEmptyManifest } from '@/utils/manifest'; // Source of truth written by the fetch layer (useManifestSource). The scene // (the City component's render effect) is a CONSUMER of this signal — it is not // derived from world.onChange. -export const MANIFEST = signal< - Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null ->(EMPTY_MANIFEST); +// +// The value union spans a fully-typed final Manifest, a bare DirNode, and the +// loose skeleton/live-update shape the stream can emit before it's fully typed. +export type ManifestValue = Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null; + +export const MANIFEST = signal(EMPTY_MANIFEST); -/** Set the current manifest (skeleton, final, or live-update). Single writer - * used by the fetch layer; views + the scene render-effect read MANIFEST. */ -export function setManifest(m: Manifest | DirNode | null): void { +/** Set the current manifest (skeleton, final, live-update, or a rollback to a + * previously-applied value). Single writer used by the fetch layer; views + + * the scene render-effect read MANIFEST. */ +export function setManifest(m: ManifestValue): void { MANIFEST.value = m ?? EMPTY_MANIFEST; } diff --git a/app/src/state/stores/serverConfig.ts b/app/src/state/stores/serverConfig.ts index 65b41433..11cd4e0f 100644 --- a/app/src/state/stores/serverConfig.ts +++ b/app/src/state/stores/serverConfig.ts @@ -1,6 +1,6 @@ // state/stores/serverConfig.ts — Runtime signal holding server capabilities // fetched once during app boot. Written by useManifestSource after getServerConfig(). -// Read by SourcePicker to decide whether to show the local-repos tab. +// Read by ProjectsView to decide whether to show the local-repos tab. import { signal } from '@preact/signals'; diff --git a/app/src/state/stores/ui.ts b/app/src/state/stores/ui.ts index a48716c6..21fab20b 100644 --- a/app/src/state/stores/ui.ts +++ b/app/src/state/stores/ui.ts @@ -8,7 +8,7 @@ import { SourceKind } from '@/utils/sources'; import { URL_PARAMS } from '@/constants/urlParams'; // These are the UI-state CONTRACTS the views render against. They live here (in -// state) so state/ stays view-independent — the SourcePicker / LoadingOverlay +// state) so state/ stays view-independent — the ProjectsView / LoadingOverlay // components import them from here, not the other way around. /** What the source picker submits when the user opens a project. */ @@ -34,33 +34,33 @@ export interface LoadingOverlayShowOpts { branch?: string; } -// ── Source picker ──────────────────────────────────────────────────────────── +// ── Projects view ──────────────────────────────────────────────────────────── -export interface SourcePickerState { +export interface ProjectsViewState { visible: boolean; opts: OpenOpts; } -export const SOURCE_PICKER = signal({ +export const PROJECTS_VIEW = signal({ visible: false, opts: {}, }); -/** Open the source picker modal. */ -export function openSourcePicker(opts: OpenOpts = {}): void { - SOURCE_PICKER.value = { visible: true, opts }; +/** Open the projects view. */ +export function openProjectsView(opts: OpenOpts = {}): void { + PROJECTS_VIEW.value = { visible: true, opts }; } -/** Close the source picker modal. */ -export function closeSourcePicker(): void { - SOURCE_PICKER.value = { ...SOURCE_PICKER.value, visible: false }; +/** Close the projects view. */ +export function closeProjectsView(): void { + PROJECTS_VIEW.value = { ...PROJECTS_VIEW.value, visible: false }; } -/** Open the picker pre-filled with the current `?src`/`?branch` — the +/** Open the view pre-filled with the current `?src`/`?branch` — the * header's "switch source" affordance. Always dismissible. */ -export function openSourcePickerForCurrentSource(): void { +export function openProjectsViewForCurrentSource(): void { const cur = new URLSearchParams(window.location.search); - openSourcePicker({ + openProjectsView({ dismissible: true, prefill: cur.has(URL_PARAMS.SRC) ? { src: cur.get(URL_PARAMS.SRC)!, branch: cur.get(URL_PARAMS.BRANCH) ?? undefined } @@ -151,8 +151,8 @@ export function closeDebug(): void { DEBUG_OPEN.value = false; } -/** True while any modal (source picker, shortcuts, debug) is open. Scene input +/** True while any modal (projects view, shortcuts, debug) is open. Scene input * handlers read this so keyboard shortcuts don't fire underneath a modal. */ export const MODAL_OPEN = computed( - () => SOURCE_PICKER.value.visible || SHORTCUTS_OPEN.value || DEBUG_OPEN.value + () => PROJECTS_VIEW.value.visible || SHORTCUTS_OPEN.value || DEBUG_OPEN.value ); diff --git a/app/src/styles/index.css b/app/src/styles/index.css index 7cb14e37..37d92be5 100644 --- a/app/src/styles/index.css +++ b/app/src/styles/index.css @@ -9,6 +9,7 @@ @import './panes.css'; @import './rows.css'; @import './cards.css'; +@import './modal.css'; @import './surfaces.css'; @import './forms.css'; @import './buttons.css'; diff --git a/app/src/styles/modal.css b/app/src/styles/modal.css new file mode 100644 index 00000000..eb1db138 --- /dev/null +++ b/app/src/styles/modal.css @@ -0,0 +1,49 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + LAYER-2 COMPONENTS — Modal chrome + ═══════════════════════════════════════════════════════════════════════════ + Shared backdrop + card layout for DebugModal, ShortcutsModal, and + LoadingOverlay (ProjectsView is a full-viewport surface, not a modal, so it + doesn't use these). Surface fill/radius/shadow comes from .card-overlay; + this file only adds backdrop positioning + the header/body column layout. + ═══════════════════════════════════════════════════════════════════════════ */ + +.modal-backdrop, +.loading-backdrop { + position: fixed; + inset: 0; + background: var(--cc-bg-backdrop); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-card { + width: 560px; + max-width: 92vw; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: inherit; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--cc-space-5) var(--cc-space-6); + border-bottom: 1px solid var(--cc-border-subtle); + font-weight: var(--cc-fw-semibold); +} +/* Modal-close × is a .btn-icon + .btn-icon--lg; this hover uses a stronger + overlay than other icon buttons, with no color change. */ +.modal-header .btn-icon:not(:disabled):hover { + background: var(--cc-overlay-bg-strong); +} + +.modal-body { + padding: var(--cc-space-5) var(--cc-space-6); + overflow-y: auto; + flex: 1; +} diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts index f0e119b9..569a0d3b 100644 --- a/app/src/types/manifest.generated.ts +++ b/app/src/types/manifest.generated.ts @@ -100,6 +100,23 @@ export interface paths { 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; @@ -162,6 +179,13 @@ export interface components { /** Commits */ commits: number; }; + /** BranchListResponse */ + BranchListResponse: { + /** Branches */ + branches: string[]; + /** Default */ + default: string | null; + }; /** BusynessThresholds */ BusynessThresholds: { /** Avg */ @@ -700,6 +724,37 @@ export interface operations { }; }; }; + 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: { diff --git a/app/src/views/DebugModal/DebugModal.tsx b/app/src/views/DebugModal/DebugModal.tsx index d83d5f52..f3043fd6 100644 --- a/app/src/views/DebugModal/DebugModal.tsx +++ b/app/src/views/DebugModal/DebugModal.tsx @@ -2,10 +2,9 @@ // the header's flag-gated bug icon (see utils/debugMode). Signal-driven like // ShortcutsModal: App mounts one instance unconditionally, it reads // DEBUG_OPEN directly and renders null when closed. Chrome -// (.modal-backdrop/.modal-card/.modal-header/.modal-body) reuses the classes -// defined in views/SourcePicker/SourcePicker.css — that component is always -// mounted, so the rules are already global; this file only adds the -// action-button layout. +// (.modal-backdrop/.modal-card/.modal-header/.modal-body) reuses the global +// classes defined in styles/modal.css; this file only adds the action-button +// layout. import './DebugModal.css'; import { useEffect, useRef } from 'preact/hooks'; diff --git a/app/src/views/ProjectsView/BranchSelect.css b/app/src/views/ProjectsView/BranchSelect.css new file mode 100644 index 00000000..4769012e --- /dev/null +++ b/app/src/views/ProjectsView/BranchSelect.css @@ -0,0 +1,18 @@ +/* ── Branch select ── */ + +.branch-select-status { + display: flex; + align-items: center; + gap: var(--cc-space-3); + font-size: var(--cc-font-lg); + color: var(--cc-text-muted); +} + +.branch-select-spinner { + animation: branch-select-spin 0.8s linear infinite; +} +@keyframes branch-select-spin { + to { + transform: rotate(360deg); + } +} diff --git a/app/src/views/ProjectsView/BranchSelect.tsx b/app/src/views/ProjectsView/BranchSelect.tsx new file mode 100644 index 00000000..92e1489c --- /dev/null +++ b/app/src/views/ProjectsView/BranchSelect.tsx @@ -0,0 +1,87 @@ +// views/ProjectsView/BranchSelect.tsx — repo-resolved branch dropdown. Fetches +// the branch list for `url` and preselects the repo default. Owns no +// url-change tracking of its own: the parent re-keys it per resolved url +// (key={url}), so a branch pick from a previous repo can never leak into the +// next one — that remount is the actual fix for bug #2, not anything here. + +import './BranchSelect.css'; +import { useEffect, useState } from 'preact/hooks'; +import { LoaderCircle } from 'lucide-preact'; +import { fetchBranches } from '@/api/branches'; + +export interface BranchSelectProps { + url: string; // a resolvable git URL, or '' to stay idle + value: string; // '' means "use the repo default" + onChange: (branch: string) => void; +} + +type State = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; branches: string[]; def: string | null } + | { status: 'error'; message: string }; + +export function BranchSelect({ url, value, onChange }: BranchSelectProps) { + const [state, setState] = useState({ status: 'idle' }); + + useEffect(() => { + if (!url) { + setState({ status: 'idle' }); + return; + } + let live = true; + setState({ status: 'loading' }); + fetchBranches(url).then( + (r) => { + if (!live) return; + setState({ status: 'ready', branches: r.branches, def: r.default }); + if (r.default) onChange(r.default); // preselect the repo default + }, + (e: unknown) => { + if (!live) return; + setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }); + } + ); + return () => { + live = false; + }; + // url is the only trigger here — the parent remounts this component per + // repo (key={url}), so there's no stale-onChange/value closure risk to + // guard against by widening this dependency list. + }, [url]); + + if (state.status === 'idle') return null; + + // .new-project-field is the shared field-wrapper layout from + // NewProjectForm.css — safe to reuse because this component only ever + // renders inside , never standalone. + return ( +
+ + {state.status === 'loading' && ( +
+ + Resolving branches… +
+ )} + {state.status === 'error' && ( +
{state.message}
+ )} + {state.status === 'ready' && ( + + )} +
+ ); +} diff --git a/app/src/views/ProjectsView/NewProjectForm.css b/app/src/views/ProjectsView/NewProjectForm.css new file mode 100644 index 00000000..ec2dc85e --- /dev/null +++ b/app/src/views/ProjectsView/NewProjectForm.css @@ -0,0 +1,55 @@ +/* ── New project form ── */ + +.new-project { + display: flex; + flex-direction: column; + gap: var(--cc-space-6); +} + +.new-project-field { + display: flex; + flex-direction: column; + gap: var(--cc-space-3); +} +.new-project-field label { + font-size: var(--cc-font-lg); + color: var(--cc-text-muted); +} +.new-project-field .form-input { + width: 100%; +} + +/* Inline notice for the disabled-local state. Quieter than .card-error: + subtle amber tint, no border, single line — the old modal's version was a + multi-paragraph wall, this one stays scannable. */ +.new-project-note { + background: var(--cc-warning-bg); + color: var(--cc-text-secondary); + border-radius: var(--cc-radius-md); + padding: var(--cc-space-4) var(--cc-space-5); + font-size: var(--cc-font-lg); +} + +.new-project-advanced-toggle { + appearance: none; + background: none; + border: none; + align-self: flex-start; + color: var(--cc-text-faint); + font: inherit; + font-size: var(--cc-font-md); + cursor: pointer; + padding: 0; +} +.new-project-advanced-toggle:hover { + color: var(--cc-text-secondary); +} + +.new-project-skip-cache { + display: flex; + align-items: center; + gap: var(--cc-space-3); + font-size: var(--cc-font-lg); + color: var(--cc-text-secondary); + margin-top: calc(var(--cc-space-4) * -1); +} diff --git a/app/src/views/ProjectsView/NewProjectForm.tsx b/app/src/views/ProjectsView/NewProjectForm.tsx new file mode 100644 index 00000000..4989ed04 --- /dev/null +++ b/app/src/views/ProjectsView/NewProjectForm.tsx @@ -0,0 +1,161 @@ +// views/ProjectsView/NewProjectForm.tsx — new-source entry. A single source +// field that classifies itself as you type (srcKind) and drives a Git URL / +// Local path segment — manual override still works via the segment itself. +// Git sources get a repo-resolved branch dropdown; skip-cache is tucked +// behind an Advanced disclosure so the common path stays a one-field form. +// +// ProjectsView unmounts this component while a load is in flight (its own +// inline progress block + Cancel take over) — `loading` here only guards +// submit against a stray double-fire in the render that flips it true. + +import './NewProjectForm.css'; +import { useState } from 'preact/hooks'; +import { SegmentedSelect } from '@/components/SegmentedSelect/SegmentedSelect'; +import { srcKind, SourceKind } from '@/utils/sources'; +import type { SourcePayload } from '@/state/stores/ui'; +import { SCAN_PROGRESS } from '@/state/stores/scanProgress'; +import { BranchSelect } from './BranchSelect'; + +export interface NewProjectFormProps { + allowLocalRepos: boolean; + prefill?: SourcePayload; + onSubmit: (payload: SourcePayload) => void; +} + +const KIND_OPTIONS = [ + { value: SourceKind.Remote, label: 'Git URL' }, + { value: SourceKind.Local, label: 'Local path' }, +]; + +// A source string worth resolving branches for: has a scheme (https://, +// ssh://, ...) or the scp-form host (user@host:path). Guards against firing +// /api/branches on every keystroke of a half-typed URL. +function looksResolvable(v: string): boolean { + return srcKind(v) === SourceKind.Remote && (/:\/\/.+\/.+/.test(v) || /^[^@]+@[^:]+:.+/.test(v)); +} + +export function NewProjectForm({ allowLocalRepos, prefill, onSubmit }: NewProjectFormProps) { + const initialKind = prefill?.src && allowLocalRepos ? srcKind(prefill.src) : SourceKind.Remote; + const [kind, setKind] = useState(initialKind); + const [source, setSource] = useState(prefill?.src ?? ''); + const [branch, setBranch] = useState(prefill?.branch ?? ''); + const [resolvedUrl, setResolvedUrl] = useState( + initialKind === SourceKind.Remote && prefill?.src && looksResolvable(prefill.src) + ? prefill.src + : '' + ); + const [skipCache, setSkipCache] = useState(false); + const [advanced, setAdvanced] = useState(false); + + const loading = SCAN_PROGRESS.value !== null; + const localOff = kind === SourceKind.Local && !allowLocalRepos; + + // Smart kind auto-select: reclassify on every keystroke, in either + // direction (a URL pasted into what was the Local tab flips back to Git). + // A full clear keeps the current kind rather than snapping to Local on + // empty input. Clicking the segment directly (onCommit below) is a manual + // override that bypasses this — it always wins until the next keystroke. + function onSourceInput(v: string) { + setSource(v); + const k = v.trim() ? srcKind(v) : kind; + // Never reclassify to Local when local repos are disabled: Local isn't a + // reachable destination there, and doing so mid-keystroke (srcKind treats a + // half-typed URL like "h" as Local) would flip localOff true and unmount + // the very field the user is typing into, dropping focus. Pin Remote. + const nextKind = k === SourceKind.Local && !allowLocalRepos ? SourceKind.Remote : k; + if (nextKind !== kind) setKind(nextKind); + if (nextKind === SourceKind.Remote) { + // Branch reset on URL change (bug #2): a stale pick from a previous + // repo must never ride along to whatever's typed now. BranchSelect is + // also remounted below (key={resolvedUrl}), so its internal fetch + // state can't straddle two repos either. + setBranch(''); + setResolvedUrl(looksResolvable(v) ? v : ''); + } + } + + const activeSrc = source.trim(); + const canSubmit = !loading && !localOff && activeSrc.length > 0; + + function submit() { + if (!canSubmit) return; + onSubmit({ + src: activeSrc, + branch: kind === SourceKind.Remote ? branch.trim() || undefined : undefined, + skipCache: skipCache || undefined, + }); + } + + return ( +
+ setKind(v as SourceKind)} + /> + + {!localOff && ( +
+ + onSourceInput((e.target as HTMLInputElement).value)} + /> +
+ )} + + {kind === SourceKind.Remote && ( + + )} + + {localOff && ( +
+ Local repos are off.{' '} + + How to enable + +
+ )} + + + {advanced && ( + + )} + + +
+ ); +} diff --git a/app/src/views/ProjectsView/ProjectsView.css b/app/src/views/ProjectsView/ProjectsView.css new file mode 100644 index 00000000..f7a0fb1e --- /dev/null +++ b/app/src/views/ProjectsView/ProjectsView.css @@ -0,0 +1,51 @@ +/* ── Projects view ────────────────────────────────────────────────────────── + Full-viewport surface (replaces the old .modal-backdrop overlay): opaque, + fixed, above the app shell, flex-centered panel. Panel surface (bg + radius + + shadow + color) comes from .card-overlay; this file adds size + layout. */ + +.projects-view { + position: fixed; + inset: 0; + background: var(--cc-bg-app); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.projects-view-panel { + width: 640px; + max-width: 92vw; + max-height: 86vh; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: inherit; +} + +.projects-view-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--cc-space-5) var(--cc-space-6); + border-bottom: 1px solid var(--cc-border-subtle); + font-weight: var(--cc-fw-semibold); +} + +.projects-view-body { + padding: var(--cc-space-5) var(--cc-space-6); + overflow-y: auto; + flex: 1; +} + +/* Inline load progress, replacing the form + recents while SCAN_PROGRESS is + non-null. Spinner + pending-label look reuse LoadingOverlay's classes + (LoadingOverlay.css is loaded globally via App), so the switch flow and the + deep-link-boot flow read as one system. */ +.projects-view-progress { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--cc-space-6); + padding: var(--cc-space-8) 0; +} diff --git a/app/src/views/ProjectsView/ProjectsView.tsx b/app/src/views/ProjectsView/ProjectsView.tsx new file mode 100644 index 00000000..b338c31c --- /dev/null +++ b/app/src/views/ProjectsView/ProjectsView.tsx @@ -0,0 +1,90 @@ +// views/ProjectsView/ProjectsView.tsx — full-viewport project switcher. Reads +// PROJECTS_VIEW + SERVER_CONFIG for open-state/prefill; App owns +// onSubmit/onCancel/onClose. Renders null when closed so the form/list state +// resets on next open. +// +// This view is the loading surface for every switch it initiates (design +// invariant): while SCAN_PROGRESS is non-null it renders inline progress in +// place of the form + recents, and (App-level) suppresses +// itself whenever this view is visible, so the two never stack. LoadingOverlay +// keeps its narrow role of deep-link cold boot (no view open yet). + +import './ProjectsView.css'; +import { useEffect } from 'preact/hooks'; +import { X } from 'lucide-preact'; +import { PROJECTS_VIEW, type SourcePayload } from '@/state/stores/ui'; +import { SERVER_CONFIG } from '@/state/stores/serverConfig'; +import { SCAN_PROGRESS } from '@/state/stores/scanProgress'; +import { PENDING_SOURCE_LABEL } from '@/state/stores/source'; +import { LOADING_STEP_LABELS, stepForPhase } from '@/constants/loadingSteps'; +import { NewProjectForm } from './NewProjectForm'; +import { RecentsList } from './RecentsList'; + +export interface ProjectsViewProps { + onSubmit: (payload: SourcePayload) => void; + onCancel: () => void; + onClose: () => void; +} + +export function ProjectsView({ onSubmit, onCancel, onClose }: ProjectsViewProps) { + const pv = PROJECTS_VIEW.value; + const scan = SCAN_PROGRESS.value; + const loading = scan !== null; + + // Escape closes the view when dismissible and not mid-load. + useEffect(() => { + if (!pv.visible || !pv.opts.dismissible) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !loading) onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [pv.visible, pv.opts.dismissible, loading, onClose]); + + if (!pv.visible) return null; + + return ( + - - - - ); -} - -// ── Signal-driven top-level component ────────────────────────────────────── -// Reads SOURCE_PICKER + SERVER_CONFIG directly for open-state/prefill, but takes -// onSubmit/onClose via props from App (App owns the stateful submit handler from -// useManifestSource and the pure closeSourcePicker store action). Returns null -// when the picker is closed so SourcePickerModal fully unmounts — its -// useState-backed form inputs reset on the next open. - -export interface SourcePickerProps { - onSubmit: (payload: SourcePayload) => void; - onClose: () => void; -} - -export function SourcePicker({ onSubmit, onClose }: SourcePickerProps) { - const sp = SOURCE_PICKER.value; - if (!sp.visible) return null; - - const serverCfg = SERVER_CONFIG.value; - const opts = sp.opts ?? {}; - const prefill = opts.prefill; - const prefillSrc = prefill?.src ?? ''; - - const pickerState: SourcePickerState = { - dismissible: opts.dismissible ?? false, - // Only default to the Local tab when local repos are enabled — otherwise a - // local-path prefill would land on the disabled-Local dead-end view. - activeTab: - prefillSrc && serverCfg.allowLocalRepos ? inferSourceTab(prefillSrc) : SourceTab.Remote, - prefillSrc, - prefillBranch: prefill?.branch ?? '', - error: opts.error ?? null, - allowLocalRepos: serverCfg.allowLocalRepos, - }; - - // Wrap pickerState in a minimal signal-like object so SourcePickerModal's - // `state.value` read works. The force-re-render write (on recent-remove) is - // no longer needed: removeRecent writes to RECENTS signal which Preact tracks. - const stateSignal = { - get value() { - return pickerState; - }, - set value(_: SourcePickerState) { - /* re-renders via RECENTS signal */ - }, - } as Signal; - - return ; -} diff --git a/app/tests/api/branches.test.ts b/app/tests/api/branches.test.ts new file mode 100644 index 00000000..19cd309e --- /dev/null +++ b/app/tests/api/branches.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fetchBranches } from '@/api/branches'; + +afterEach(() => vi.restoreAllMocks()); + +describe('fetchBranches', () => { + it('returns branches + default on ok', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ branches: ['main', 'dev'], default: 'main' }), { + status: 200, + }) + ) + ); + const r = await fetchBranches('https://github.com/o/r'); + expect(r.branches).toEqual(['main', 'dev']); + expect(r.default).toBe('main'); + }); + + it('rejects with the server error on non-2xx', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ detail: 'repository not found' }), { status: 404 }) + ) + ); + await expect(fetchBranches('https://github.com/o/nope')).rejects.toThrow(/not found/i); + }); +}); diff --git a/app/tests/api/manifest.test.ts b/app/tests/api/manifest.test.ts index 69b54854..27d9888f 100644 --- a/app/tests/api/manifest.test.ts +++ b/app/tests/api/manifest.test.ts @@ -38,7 +38,9 @@ const fakeManifest = { root: '/r', tree: { type: 'directory' } }; describe('streamManifest (EventSource)', () => { it('maps named SSE events to ScanStreamEvents in order and stops after manifest-complete', async () => { const { ctor, last } = makeES(); - const it = streamManifest('/api/manifest?src=x', ctor)[Symbol.asyncIterator](); + const it = streamManifest('/api/manifest?src=x', { EventSourceImpl: ctor })[ + Symbol.asyncIterator + ](); const es = last(); es.emit('scan-progress', JSON.stringify({ display_root: 'x', files_scanned: 3 })); es.emit('manifest-partial', JSON.stringify({ manifest: fakeManifest })); @@ -57,7 +59,7 @@ describe('streamManifest (EventSource)', () => { it('maps a clone-progress event with display_root', async () => { const { ctor, last } = makeES(); - const it = streamManifest('/api/manifest', ctor)[Symbol.asyncIterator](); + const it = streamManifest('/api/manifest', { EventSourceImpl: ctor })[Symbol.asyncIterator](); last().emit('clone-progress', JSON.stringify({ display_root: 'https://example.com/foo.git' })); const a = await it.next(); const ev = a.value as ScanStreamEvent; @@ -70,7 +72,7 @@ describe('streamManifest (EventSource)', () => { it('emits a terminal Error event for a server-sent error', async () => { const { ctor, last } = makeES(); - const it = streamManifest('/api/manifest', ctor)[Symbol.asyncIterator](); + const it = streamManifest('/api/manifest', { EventSourceImpl: ctor })[Symbol.asyncIterator](); last().emit('error', JSON.stringify({ error: 'boom' })); const a = await it.next(); expect(a.value).toEqual({ phase: ScanPhase.Error, error: 'boom' }); @@ -80,15 +82,45 @@ describe('streamManifest (EventSource)', () => { it('rejects on a transport-level error (bare error event, no data)', async () => { const { ctor, last } = makeES(); - const it = streamManifest('/api/manifest', ctor)[Symbol.asyncIterator](); + const it = streamManifest('/api/manifest', { EventSourceImpl: ctor })[Symbol.asyncIterator](); last().emit('error'); // no data → connection failure await expect(it.next()).rejects.toThrow(/connection failed/i); }); it('rejects on a malformed event payload instead of hanging forever', async () => { const { ctor, last } = makeES(); - const it = streamManifest('/api/manifest', ctor)[Symbol.asyncIterator](); + const it = streamManifest('/api/manifest', { EventSourceImpl: ctor })[Symbol.asyncIterator](); last().emit('manifest-partial', '{not valid json'); // truncated/garbage frame await expect(it.next()).rejects.toThrow(/malformed/i); }); + + it('ends iteration when the signal aborts (no more events delivered)', async () => { + const { ctor, last } = makeES(); + const ac = new AbortController(); + const it = streamManifest('/api/manifest', { + signal: ac.signal, + EventSourceImpl: ctor, + })[Symbol.asyncIterator](); + const next = it.next(); // pending — no events yet + ac.abort(); + const r = await next; + expect(r.done).toBe(true); + expect(last().closed).toBe(true); // EventSource was closed on abort + }); + + it('ends immediately when the signal is already aborted before iteration', async () => { + const { ctor, last } = makeES(); + const ac = new AbortController(); + ac.abort(); // aborted BEFORE the iterator is created + const it = streamManifest('/api/manifest', { + signal: ac.signal, + EventSourceImpl: ctor, + })[Symbol.asyncIterator](); + // The abort-wiring block runs after finish() is declared, so an + // already-aborted signal closes the stream at iterator-creation time + // without a TDZ ReferenceError. + expect(last().closed).toBe(true); + const r = await it.next(); + expect(r.done).toBe(true); + }); }); diff --git a/app/tests/city/interaction/inputHandlers.test.ts b/app/tests/city/interaction/inputHandlers.test.ts index 29aebf08..181326b7 100644 --- a/app/tests/city/interaction/inputHandlers.test.ts +++ b/app/tests/city/interaction/inputHandlers.test.ts @@ -1,6 +1,6 @@ // city/interaction/inputHandlers.test.ts — the scene's document-level keydown // handler must not fire scene keybindings (Esc-deselect, R, F) while a modal -// (Shortcuts/Debug/SourcePicker) is open. The handler bails out early when the +// (Shortcuts/Debug/ProjectsView) is open. The handler bails out early when the // MODAL_OPEN signal is set — see the "modal owns keyboard input" guard in // inputHandlers.ts, right after the text-input early-return. // diff --git a/app/tests/components/loadingOverlay.test.tsx b/app/tests/components/loadingOverlay.test.tsx index d0648b32..a0b3104f 100644 --- a/app/tests/components/loadingOverlay.test.tsx +++ b/app/tests/components/loadingOverlay.test.tsx @@ -3,6 +3,7 @@ import { render } from 'preact'; import { LoadingOverlay } from '@/components/LoadingOverlay/LoadingOverlay'; import { LOADING_OVERLAY, + PROJECTS_VIEW, showLoadingOverlay, hideLoadingOverlay, setLoadingStep, @@ -38,6 +39,7 @@ afterEach(() => { render(null, container); container.remove(); PENDING_SOURCE_LABEL.value = null; + PROJECTS_VIEW.value = { visible: false, opts: {} }; }); describe('LoadingOverlay', () => { @@ -45,6 +47,20 @@ describe('LoadingOverlay', () => { expect(container.querySelector('.loading-backdrop')).toBeNull(); }); + // is the loading surface for any switch it initiates; this + // overlay's narrow remaining job is a deep-link cold boot with no view + // open. Two full-viewport surfaces stacking would leave the overlay's + // no-controls backdrop on top of the view's Cancel button. + it('stays hidden while the projects view is open, even mid-load', async () => { + showLoadingOverlay({ kind: SourceKind.Remote, label: 'owner/repo' }); + await flush(); + expect(container.querySelector('.loading-backdrop')).not.toBeNull(); + + PROJECTS_VIEW.value = { visible: true, opts: {} }; + await flush(); + expect(container.querySelector('.loading-backdrop')).toBeNull(); + }); + it('show reveals the overlay', async () => { showLoadingOverlay({ kind: SourceKind.Local, label: 'foo' }); await flush(); diff --git a/app/tests/hooks/useManifestSource.test.ts b/app/tests/hooks/useManifestSource.test.ts new file mode 100644 index 00000000..35068572 --- /dev/null +++ b/app/tests/hooks/useManifestSource.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { loadSource, cancelLoad } from '@/hooks/useManifestSource'; +import { SOURCE_ERROR, CURRENT_SOURCE } from '@/state/stores/source'; +import { MANIFEST } from '@/state/stores/manifest'; + +// EventSource stub with driveable events: records listeners so a test can emit +// a named SSE event (e.g. a `manifest-partial` skeleton), and records +// instances so a test can assert the stream was closed on abort. +class StubEventSource { + static instances: StubEventSource[] = []; + closed = false; + private listeners: Record void)[]> = {}; + constructor(public url: string) { + StubEventSource.instances.push(this); + } + addEventListener(name: string, handler: (e: unknown) => void): void { + (this.listeners[name] ??= []).push(handler); + } + close(): void { + this.closed = true; + } + emit(name: string, data: string): void { + for (const h of this.listeners[name] ?? []) h({ data }); + } +} + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); + +describe('useManifestSource loadSource cancellation', () => { + let originalEventSource: typeof EventSource; + + beforeEach(() => { + originalEventSource = globalThis.EventSource; + StubEventSource.instances = []; + (globalThis as unknown as { EventSource: unknown }).EventSource = StubEventSource; + SOURCE_ERROR.value = null; + }); + + afterEach(() => { + (globalThis as unknown as { EventSource: unknown }).EventSource = originalEventSource; + }); + + it('canceling a load leaves SOURCE_ERROR null and CURRENT_SOURCE unchanged', async () => { + const before = CURRENT_SOURCE.value; + + const p = loadSource({ src: 'https://github.com/o/r' }); // starts the load + cancelLoad(); // aborts via loadController before any event arrives + + await p; + + expect(SOURCE_ERROR.value).toBeNull(); + expect(CURRENT_SOURCE.value).toBe(before); + expect(StubEventSource.instances[0]?.closed).toBe(true); + }); + + it('canceling AFTER a skeleton manifest does not commit it as CURRENT_SOURCE', async () => { + const before = CURRENT_SOURCE.value; + + const p = loadSource({ src: 'https://github.com/o/r' }); + await flush(); // let the for-await attach its event listeners + + // Skeleton arrives, then the user cancels before the final manifest. An + // aborted stream ends done (not a throw), so pumpManifestStream RETURNS the + // skeleton — the success path must still refuse to commit it. + StubEventSource.instances[0]!.emit( + 'manifest-partial', + JSON.stringify({ manifest: { root: '/r', tree: { type: 'directory' }, signature: 'sig' } }) + ); + await flush(); + cancelLoad(); + + await p; + + expect(CURRENT_SOURCE.value).toBe(before); // NOT committed + expect(SOURCE_ERROR.value).toBeNull(); // cancel is not an error + expect(StubEventSource.instances[0]?.closed).toBe(true); + }); + + it('canceling AFTER a skeleton rolls MANIFEST back to the prior city', async () => { + // City A is already applied (source unchanged throughout this load of B). + const cityA = { root: '/a', tree: { type: 'directory' }, signature: 'sig-a' }; + MANIFEST.value = cityA; + const before = CURRENT_SOURCE.value; + + const p = loadSource({ src: 'https://github.com/o/b' }); + await flush(); // let the for-await attach its event listeners + + // B's skeleton streams into MANIFEST (behind the overlay)... + StubEventSource.instances[0]!.emit( + 'manifest-partial', + JSON.stringify({ manifest: { root: '/b', tree: { type: 'directory' }, signature: 'sig-b' } }) + ); + await flush(); + expect(MANIFEST.value).not.toBe(cityA); // sanity: B's skeleton IS applied mid-load + + cancelLoad(); // ...then the user cancels before B's final arrives + await p; + + expect(MANIFEST.value).toBe(cityA); // rolled back to city A + expect(CURRENT_SOURCE.value).toBe(before); // never committed B + expect(SOURCE_ERROR.value).toBeNull(); // cancel is not an error + }); +}); diff --git a/app/tests/state/stores/ui.test.ts b/app/tests/state/stores/ui.test.ts new file mode 100644 index 00000000..62ae0d77 --- /dev/null +++ b/app/tests/state/stores/ui.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { PROJECTS_VIEW, openProjectsView, closeProjectsView, MODAL_OPEN } from '@/state/stores/ui'; + +describe('PROJECTS_VIEW', () => { + it('opens with opts and closes', () => { + openProjectsView({ dismissible: true }); + expect(PROJECTS_VIEW.value.visible).toBe(true); + expect(PROJECTS_VIEW.value.opts.dismissible).toBe(true); + expect(MODAL_OPEN.value).toBe(true); + closeProjectsView(); + expect(PROJECTS_VIEW.value.visible).toBe(false); + expect(MODAL_OPEN.value).toBe(false); + }); +}); diff --git a/app/tests/views/ProjectsView/BranchSelect.test.tsx b/app/tests/views/ProjectsView/BranchSelect.test.tsx new file mode 100644 index 00000000..06da84a8 --- /dev/null +++ b/app/tests/views/ProjectsView/BranchSelect.test.tsx @@ -0,0 +1,98 @@ +// Native-harness tests for BranchSelect — mirrors app/tests/layout/leftSidebar.test.tsx's +// render/flush/container pattern (this repo has no @testing-library/preact dependency). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'preact'; +import { BranchSelect } from '@/views/ProjectsView/BranchSelect'; +import * as branchesApi from '@/api/branches'; +import { flush, drainAsync } from '../../_helpers/preact'; + +describe('BranchSelect', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + render(null, container); + document.body.removeChild(container); + vi.restoreAllMocks(); + }); + + it('renders nothing while idle (no url)', async () => { + render( {}} />, container); + await flush(); + expect(container.querySelector('.branch-select')).toBeNull(); + }); + + it('shows a resolving state, then the dropdown with the repo default preselected and marked', async () => { + // Manually-controlled promise (rather than mockResolvedValue) so the + // loading assertion below can't race past the resolution on the + // microtask queue — it only settles once resolveFetch is called. + let resolveFetch!: (r: { branches: string[]; default: string | null }) => void; + vi.spyOn(branchesApi, 'fetchBranches').mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const onChange = vi.fn(); + + render(, container); + // The mocked fetch never settles until resolveFetch() runs below, so it's + // safe to drain generously here — the component can only be idle or + // loading, never past it (Preact's effect scheduling has a real-timer + // hop, so a single microtask flush() is not enough to observe this). + await drainAsync(); + expect(container.querySelector('.branch-select-status')?.textContent).toMatch(/resolving/i); + + resolveFetch({ branches: ['main', 'dev'], default: 'main' }); + await drainAsync(); + + // The repo default is preselected via onChange (the parent owns `value`). + expect(onChange).toHaveBeenCalledWith('main'); + const select = container.querySelector('select')!; + expect(select).not.toBeNull(); + const optionTexts = Array.from(select.options).map((o) => o.textContent); + expect(optionTexts).toContain('main (default)'); + expect(optionTexts).toContain('dev'); + }); + + it('shows the server error message inline on resolution failure, with no dropdown', async () => { + vi.spyOn(branchesApi, 'fetchBranches').mockRejectedValue(new Error('repository not found')); + + render( + {}} />, + container + ); + await drainAsync(); + + expect(container.textContent).toMatch(/repository not found/i); + expect(container.querySelector('select')).toBeNull(); + }); + + it('re-resolves when the url prop changes (parent typically remounts via key instead)', async () => { + const resolve = vi.spyOn(branchesApi, 'fetchBranches'); + resolve.mockResolvedValueOnce({ branches: ['main'], default: 'main' }); + const onChange = vi.fn(); + + render( + , + container + ); + await drainAsync(); + expect(resolve).toHaveBeenCalledWith('https://github.com/o/first'); + + resolve.mockResolvedValueOnce({ branches: ['trunk'], default: 'trunk' }); + render( + , + container + ); + await drainAsync(); + + expect(resolve).toHaveBeenCalledWith('https://github.com/o/second'); + expect(onChange).toHaveBeenCalledWith('trunk'); + }); +}); diff --git a/app/tests/views/ProjectsView/NewProjectForm.test.tsx b/app/tests/views/ProjectsView/NewProjectForm.test.tsx new file mode 100644 index 00000000..bb054725 --- /dev/null +++ b/app/tests/views/ProjectsView/NewProjectForm.test.tsx @@ -0,0 +1,189 @@ +// Native-harness tests for NewProjectForm — mirrors app/tests/layout/leftSidebar.test.tsx's +// render/flush/container pattern (this repo has no @testing-library/preact dependency). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'preact'; +import { NewProjectForm } from '@/views/ProjectsView/NewProjectForm'; +import * as branchesApi from '@/api/branches'; +import { SCAN_PROGRESS } from '@/state/stores/scanProgress'; +import { flush, drainAsync } from '../../_helpers/preact'; + +function setInput(el: HTMLInputElement, value: string) { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); +} + +function activeSegment(container: HTMLElement): string | undefined { + return container.querySelector('.btn-toggle.is-active')?.textContent ?? undefined; +} + +describe('NewProjectForm', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + render(null, container); + document.body.removeChild(container); + SCAN_PROGRESS.value = null; + vi.restoreAllMocks(); + }); + + it('auto-selects Git for a URL and Local for a bare path, but a manual click still overrides it', async () => { + render( {}} />, container); + await flush(); + expect(activeSegment(container)).toBe('Git URL'); + + const urlInput = container.querySelector('input[aria-label="URL"]')!; + setInput(urlInput, '/Users/thalida/repo'); + await flush(); + expect(activeSegment(container)).toBe('Local path'); + + const pathInput = container.querySelector('input[aria-label="Path"]')!; + setInput(pathInput, 'https://github.com/o/r'); + await flush(); + expect(activeSegment(container)).toBe('Git URL'); + + // Manual override: force Local even though the typed text still looks remote. + const localTab = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'Local path' + )!; + localTab.click(); + await flush(); + expect(activeSegment(container)).toBe('Local path'); + }); + + it('resets the branch when the URL changes to a different repo (bug #2)', async () => { + const resolve = vi.spyOn(branchesApi, 'fetchBranches'); + resolve.mockResolvedValueOnce({ branches: ['main', 'feat'], default: 'main' }); + const onSubmit = vi.fn(); + + render(, container); + await flush(); + + const urlInput = container.querySelector('input[aria-label="URL"]')!; + setInput(urlInput, 'https://github.com/o/first'); + await drainAsync(); + + // User picks the non-default branch on the first repo. + const select = container.querySelector('select')!; + select.value = 'feat'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await flush(); + expect(select.value).toBe('feat'); + + // Now point the field at a different repo entirely. This repo has no + // detectable default (default: null), so nothing auto-preselects a + // branch for it — if the stale 'feat' pick isn't explicitly cleared, + // it's the only branch value left standing and rides along unnoticed. + resolve.mockResolvedValueOnce({ branches: ['main'], default: null }); + setInput(urlInput, 'https://github.com/o/second'); + await drainAsync(); + + const submitBtn = container.querySelector('[aria-label="Open project"]')!; + submitBtn.click(); + await flush(); + + const payload = onSubmit.mock.calls.at(-1)?.[0]; + expect(payload.src).toBe('https://github.com/o/second'); + // The load-bearing assertion: 'feat' (the FIRST repo's pick) must never + // ride along to the second repo's submit. + expect(payload.branch).not.toBe('feat'); + expect(payload.branch).toBeUndefined(); + }); + + it('stays submit-enabled when branch resolution fails (forgiving — server resolves default)', async () => { + vi.spyOn(branchesApi, 'fetchBranches').mockRejectedValue(new Error('repository not found')); + const onSubmit = vi.fn(); + + render(, container); + await flush(); + + const urlInput = container.querySelector('input[aria-label="URL"]')!; + setInput(urlInput, 'https://github.com/o/nope'); + await drainAsync(); + + expect(container.textContent).toMatch(/repository not found/i); + const submitBtn = container.querySelector('[aria-label="Open project"]')!; + expect(submitBtn.disabled).toBe(false); + + submitBtn.click(); + await flush(); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ src: 'https://github.com/o/nope' }) + ); + }); + + it('demotes skip-cache to an off-by-default Advanced disclosure', async () => { + render( {}} />, container); + await flush(); + + expect(container.querySelector('input[type="checkbox"]')).toBeNull(); + + const toggle = container.querySelector('.new-project-advanced-toggle')!; + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + toggle.click(); + await flush(); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + const checkbox = container.querySelector('input[type="checkbox"]')!; + expect(checkbox).not.toBeNull(); + expect(checkbox.checked).toBe(false); + }); + + it('shows a concise disabled-local note and blocks submit when local repos are off', async () => { + render( {}} />, container); + await flush(); + + const localTab = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'Local path' + )!; + localTab.click(); + await flush(); + + expect(container.textContent).toMatch(/local repos are off/i); + // Concise, not a wall: no leftover path input competing with the note. + expect(container.querySelector('input[aria-label="Path"]')).toBeNull(); + + const submitBtn = container.querySelector('[aria-label="Open project"]')!; + expect(submitBtn.disabled).toBe(true); + }); + + it('never uses an em-dash in the disabled-local copy', async () => { + render( {}} />, container); + await flush(); + const localTab = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'Local path' + )!; + localTab.click(); + await flush(); + + expect(container.querySelector('.new-project-note')?.textContent).not.toMatch(/—/); + }); + + it('keeps the URL field mounted while typing a git URL char-by-char when local repos are off', async () => { + // Regression guard: srcKind() classifies any string without "://" or a + // user@host: prefix as Local, so the very first keystroke of a URL ("h") + // used to flip kind→Local. With local repos off that made localOff true, + // which unmounted the shared source input mid-keystroke and dropped focus. + // With local disabled, Local isn't a reachable destination, so kind must + // pin to Git and the field must never disappear while the user types. + render( {}} />, container); + await flush(); + + const urlInput = container.querySelector('input[aria-label="URL"]')!; + expect(urlInput).not.toBeNull(); + + for (const chunk of ['h', 'ht', 'htt', 'http']) { + setInput(urlInput, chunk); + await flush(); + // The input the user is typing into must stay in the DOM at every step. + expect(container.querySelector('input[aria-label="URL"]')).not.toBeNull(); + // ...and kind stays Git, never flipping to the unreachable Local. + expect(activeSegment(container)).toBe('Git URL'); + } + }); +}); diff --git a/app/tests/views/ProjectsView/ProjectsView.test.tsx b/app/tests/views/ProjectsView/ProjectsView.test.tsx new file mode 100644 index 00000000..92b7e2fb --- /dev/null +++ b/app/tests/views/ProjectsView/ProjectsView.test.tsx @@ -0,0 +1,135 @@ +// Native-harness tests for ProjectsView — mirrors app/tests/layout/leftSidebar.test.tsx's +// render/flush/container pattern (this repo has no @testing-library/preact dependency). +// +// Focus: the inline-load-progress cutover (#77 Task 9) — while SCAN_PROGRESS is +// non-null the view shows progress + a working Cancel and the form/recents (the +// "second load" surface) are unmounted, not just visually disabled. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'preact'; +import { ProjectsView } from '@/views/ProjectsView/ProjectsView'; +import { PROJECTS_VIEW, openProjectsView, closeProjectsView } from '@/state/stores/ui'; +import { SCAN_PROGRESS } from '@/state/stores/scanProgress'; +import { PENDING_SOURCE_LABEL } from '@/state/stores/source'; +import { SERVER_CONFIG } from '@/state/stores/serverConfig'; +import { RECENTS } from '@/state/stores/source'; +import { ScanPhase } from '@/api/manifest'; +import { SourceKind } from '@/utils/sources'; +import { flush, drainAsync } from '../../_helpers/preact'; + +describe('ProjectsView', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + SERVER_CONFIG.value = { allowLocalRepos: true }; + }); + + afterEach(() => { + render(null, container); + document.body.removeChild(container); + closeProjectsView(); + SCAN_PROGRESS.value = null; + PENDING_SOURCE_LABEL.value = null; + RECENTS.value = []; + vi.restoreAllMocks(); + }); + + it('renders nothing when closed', async () => { + render( {}} onCancel={() => {}} onClose={() => {}} />, container); + await flush(); + expect(container.querySelector('.projects-view')).toBeNull(); + }); + + it('renders the new-project form when open and idle', async () => { + openProjectsView({ dismissible: true }); + render( {}} onCancel={() => {}} onClose={() => {}} />, container); + await flush(); + expect(container.querySelector('.new-project')).not.toBeNull(); + expect(container.querySelector('.projects-view-progress')).toBeNull(); + }); + + it('shows inline progress and hides the form/recents while a load is in flight', async () => { + openProjectsView({ dismissible: true }); + RECENTS.value = [ + { src: 'https://github.com/o/r', branch: 'main', label: 'o/r', lastOpenedAt: 1 }, + ]; + render( {}} onCancel={() => {}} onClose={() => {}} />, container); + await flush(); + expect(container.querySelector('.recents')).not.toBeNull(); + + SCAN_PROGRESS.value = { kind: SourceKind.Remote, label: 'o/r', phase: ScanPhase.CloneProgress }; + PENDING_SOURCE_LABEL.value = 'o/r'; + await flush(); + + // The "second load" surface is gone, not just disabled — nothing left to + // click into while the current load streams. + expect(container.querySelector('.new-project')).toBeNull(); + expect(container.querySelector('.recents')).toBeNull(); + + const progress = container.querySelector('.projects-view-progress'); + expect(progress).not.toBeNull(); + expect(progress?.textContent).toContain('o/r'); + expect(progress?.textContent).toMatch(/Cloning/i); + expect(container.querySelector('.loading-spinner')).not.toBeNull(); + }); + + it('wires the Cancel button to onCancel', async () => { + openProjectsView({ dismissible: true }); + const onCancel = vi.fn(); + render( {}} onCancel={onCancel} onClose={() => {}} />, container); + SCAN_PROGRESS.value = { kind: SourceKind.Local, label: 'proj', phase: null }; + await flush(); + + const cancelBtn = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'Cancel' + )!; + expect(cancelBtn).toBeTruthy(); + cancelBtn.click(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('does not show a close button while loading, even when dismissible', async () => { + openProjectsView({ dismissible: true }); + render( {}} onCancel={() => {}} onClose={() => {}} />, container); + SCAN_PROGRESS.value = { kind: SourceKind.Local, label: 'proj', phase: null }; + await flush(); + expect(container.querySelector('[aria-label="Close"]')).toBeNull(); + }); + + it('drops a stale error banner once a new load starts', async () => { + openProjectsView({ dismissible: true, error: 'repository not found' }); + render( {}} onCancel={() => {}} onClose={() => {}} />, container); + await flush(); + expect(container.textContent).toMatch(/repository not found/i); + + SCAN_PROGRESS.value = { kind: SourceKind.Remote, label: 'o/r', phase: null }; + await flush(); + expect(container.textContent).not.toMatch(/repository not found/i); + }); + + it('closes on Escape only when dismissible and not loading', async () => { + openProjectsView({ dismissible: true }); + const onClose = vi.fn(); + render( {}} onCancel={() => {}} onClose={onClose} />, container); + await flush(); + + // The Escape listener rebinds via a useEffect keyed on `loading`; that + // commit needs an rAF-scale tick in jsdom (see _helpers/preact.ts), not + // just a microtask flush, so drainAsync rather than flush() here. + SCAN_PROGRESS.value = { kind: SourceKind.Local, label: 'proj', phase: null }; + await drainAsync(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onClose).not.toHaveBeenCalled(); + + SCAN_PROGRESS.value = null; + await drainAsync(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('reflects the PROJECTS_VIEW signal directly', () => { + expect(PROJECTS_VIEW.value.visible).toBe(false); + }); +}); diff --git a/app/tests/views/ProjectsView/RecentsList.test.tsx b/app/tests/views/ProjectsView/RecentsList.test.tsx new file mode 100644 index 00000000..e28cbdaa --- /dev/null +++ b/app/tests/views/ProjectsView/RecentsList.test.tsx @@ -0,0 +1,109 @@ +// Native-harness tests for RecentsList — mirrors app/tests/layout/leftSidebar.test.tsx's +// render/flush/container pattern (this repo has no @testing-library/preact dependency). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'preact'; +import { RECENTS, CURRENT_SOURCE } from '@/state/stores/source'; +import { SERVER_CONFIG } from '@/state/stores/serverConfig'; +import { RecentsList } from '@/views/ProjectsView/RecentsList'; +import * as manifestApi from '@/api/manifest'; +import { flush } from '../../_helpers/preact'; + +describe('RecentsList', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + RECENTS.value = [ + { + src: 'https://github.com/o/alpha', + branch: 'main', + branchIsDefault: true, + label: 'o/alpha', + lastOpenedAt: 2, + }, + { src: 'https://github.com/o/beta', branch: 'dev', label: 'o/beta', lastOpenedAt: 1 }, + ]; + CURRENT_SOURCE.value = { src: 'https://github.com/o/alpha', branch: 'main' }; + }); + + afterEach(() => { + render(null, container); + document.body.removeChild(container); + RECENTS.value = []; + CURRENT_SOURCE.value = null; + SERVER_CONFIG.value = { allowLocalRepos: false }; + }); + + it('marks the CURRENT_SOURCE row active and tags default branches', async () => { + render( {}} />, container); + await flush(); + + const activeRow = container.querySelector('.recent-row--active'); + expect(activeRow).toBeTruthy(); + expect(activeRow?.textContent).toContain('o/alpha'); + expect(container.textContent).toContain('(default)'); + }); + + it('filters by label/src as you type', async () => { + render( {}} />, container); + await flush(); + + const filterInput = container.querySelector('.recents-filter')!; + filterInput.value = 'beta'; + filterInput.dispatchEvent(new Event('input', { bubbles: true })); + await flush(); + + const labels = Array.from(container.querySelectorAll('.recent-label')).map( + (el) => el.textContent + ); + expect(labels).not.toContain('o/alpha'); + expect(labels).toContain('o/beta'); + }); + + it('remove is non-destructive: forgets the entry, does not touch the cache', async () => { + const spy = vi.spyOn(manifestApi, 'clearManifestCache'); + render( {}} />, container); + await flush(); + + const removeButtons = container.querySelectorAll( + '[aria-label="Remove from recents"]' + ); + removeButtons[0].click(); // alpha row -> ask + await flush(); + + const confirmButtons = Array.from(container.querySelectorAll('button')).filter( + (b) => b.textContent === 'Remove' + ); + expect(confirmButtons.length).toBeGreaterThan(0); + (confirmButtons[0] as HTMLButtonElement).click(); + await flush(); + + expect(RECENTS.value.find((r) => r.label === 'o/alpha')).toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('supports keyboard navigation: arrow keys move the highlight, Enter opens it', async () => { + const onOpen = vi.fn(); + render(, container); + await flush(); + + const list = container.querySelector('.recents')!; + // Cursor starts at 0 (alpha, which is active/disabled-for-open); move down to beta. + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await flush(); + + const highlighted = container.querySelector('.recent-row--highlighted'); + expect(highlighted?.textContent).toContain('o/beta'); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await flush(); + + expect(onOpen).toHaveBeenCalledWith({ src: 'https://github.com/o/beta', branch: 'dev' }); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await flush(); + expect(container.querySelector('.recent-row--highlighted')?.textContent).toContain('o/alpha'); + }); +}); diff --git a/app/tests/views/sourcePicker.test.tsx b/app/tests/views/sourcePicker.test.tsx deleted file mode 100644 index f16affc2..00000000 --- a/app/tests/views/sourcePicker.test.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { render } from 'preact'; -import { act } from 'preact/test-utils'; -import { signal } from '@preact/signals'; -import { SourcePickerModal, SourceTab, inferSourceTab } from '@/views/SourcePicker/SourcePicker'; -import type { SourcePickerState } from '@/views/SourcePicker/SourcePicker'; -import type { SourcePayload, OpenOpts } from '@/state/stores/ui'; -import { pushRecent, RECENTS } from '@/state/stores/source'; -import { flush } from '../_helpers/preact'; - -describe('SourcePicker', () => { - let container: HTMLDivElement; - let onSubmit: (s: SourcePayload) => void; - let onClose: () => void; - let state: ReturnType>; - let allowLocalRepos = true; - - // Mirrors the factory's createSourcePicker({ onSubmit, allowLocalRepos }): - // build a fresh state signal (closed) and render the component into a - // container. The factory mounted into #source-picker-root; the component - // takes a container we own here. - function createPicker(opts: { allowLocalRepos: boolean }): void { - allowLocalRepos = opts.allowLocalRepos; - // "Closed" = nothing mounted, mirroring the production wrapper which renders - // null when the picker isn't visible. open() mounts the component. - state = signal({ - dismissible: false, - activeTab: SourceTab.Remote, - prefillSrc: '', - prefillBranch: '', - error: null, - allowLocalRepos, - }); - } - - // Maps the factory's `.open(opts)` onto a mount. The component's form inputs - // are useState-backed and seeded on MOUNT from state, so to get fresh inputs - // each open() we unmount (render null) then mount fresh — mirroring the - // production wrapper, which renders null when the picker is closed. - async function open(opts: OpenOpts = {}): Promise { - const prefillSrc = opts.prefill?.src ?? ''; - // Tab derivation mirrors the factory's deriveTabFromPrefill: a prefill - // source picks its tab via inferSourceTab, BUT when local repos are - // disabled we never default to the Local tab. - let activeTab: SourceTab; - if (!prefillSrc) { - activeTab = SourceTab.Remote; - } else if (!allowLocalRepos) { - activeTab = SourceTab.Remote; - } else { - activeTab = inferSourceTab(prefillSrc); - } - - // Unmount any prior render so useState re-initialises on re-open (mirrors - // the production wrapper returning null when closed), then mount fresh. - act(() => render(null, container)); - await flush(); - state.value = { - dismissible: opts.dismissible ?? false, - activeTab, - prefillSrc, - prefillBranch: opts.prefill?.branch ?? '', - error: opts.error ?? null, - allowLocalRepos, - }; - act(() => { - render(, container); - }); - await flush(); - } - - // PaneTabs renders `