/** * Project catalog — a localStorage-backed directory of every Nwire project * the user has opened in Studio. Each visit writes a snapshot so the * `/projects` page can list "your projects" with stats even when the * Studio instance for that project isn't currently running. * * Storage key: `nwire.projects` → Record * * Snapshots are per-cwd so multiple worktrees of the same repo show up * as siblings, which is what a polyrepo dev wants. */ const STORAGE_KEY = "nwire.projects"; /** * What we remember about a project across visits. Everything but `cwd` + * `name` is "best-known so far" — the snapshot the project's own Studio * wrote on its last visit. The catalog page reconciles this against live * status (running processes) fetched server-side from each cwd's * `.nwire/processes/` registry. */ export interface ProjectSnapshot { readonly cwd: string; readonly name: string; /** ISO timestamp of the last time this project's Studio was open. */ readonly lastVisited: string; /** Composition counts from the last manifest seen. Optional — early * visits before scan finishes may not have them. */ readonly composition?: { readonly apps: number; readonly plugins: number; readonly actions: number; readonly events: number; readonly resolvers?: number; readonly workflows?: number; }; } /** Read every known project. Returns an empty record on first run. */ export function loadCatalog(): Record { if (typeof localStorage === "undefined") return {}; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; return typeof parsed === "object" && parsed !== null ? parsed : {}; } catch { return {}; } } /** * Upsert this Studio's current project. Called on App mount whenever the * `/__nwire/project` + cache fetches resolve. Existing snapshots are * merged so we never lose the old composition during a fresh load. */ export function upsertCurrent(snapshot: ProjectSnapshot): void { if (typeof localStorage === "undefined") return; try { const catalog = loadCatalog(); const prior = catalog[snapshot.cwd]; catalog[snapshot.cwd] = { ...prior, ...snapshot, // Preserve the prior composition until the new visit produces one. composition: snapshot.composition ?? prior?.composition, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(catalog)); } catch { // localStorage may be full or disabled; the catalog page just sees // an empty entry next time. } } /** * Active-project state — which catalog entry every fetch is scoped to. Reads * + writes through localStorage so a Studio refresh keeps the same view. * Returns null when no choice has been recorded (caller should default to * the launch-time project from `/__nwire/project`). */ const ACTIVE_KEY = "nwire.activeProject"; const ACTIVE_SLUG_KEY = "nwire.studio.activeSlug"; export function getActiveProjectCwd(): string | null { if (typeof localStorage === "undefined") return null; try { return localStorage.getItem(ACTIVE_KEY); } catch { return null; } } export function setActiveProjectCwd(cwd: string | null): void { if (typeof localStorage === "undefined") return; try { if (cwd) localStorage.setItem(ACTIVE_KEY, cwd); else localStorage.removeItem(ACTIVE_KEY); } catch { // ignore } } /** * Derive a stable URL slug from a project's package name (preferred) or * the basename of its cwd (fallback). Slugs are kebab-cased, alphanum * only — anything else collapses to `-`. */ export function projectSlug(snapshot: { name?: string; cwd: string }): string { const raw = ( snapshot.name && snapshot.name.length > 0 ? snapshot.name : (snapshot.cwd.split("/").filter(Boolean).pop() ?? "project") ) .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); return raw || "project"; } /** Reverse-map a URL slug back to its cwd via the catalog. */ export function cwdForSlug(slug: string): string | null { const catalog = loadCatalog(); for (const snapshot of Object.values(catalog)) { if (projectSlug(snapshot) === slug) return snapshot.cwd; } return null; } export function setActiveSlug(slug: string | null): void { if (typeof localStorage === "undefined") return; try { if (slug) localStorage.setItem(ACTIVE_SLUG_KEY, slug); else localStorage.removeItem(ACTIVE_SLUG_KEY); } catch { // ignore } } /** * `fetch` wrapper that appends `?project=` to every Studio-internal * URL. The server's `targetCwd(req)` reads this and serves the right * project's manifest, scripts, processes, etc. — single-project behavior * is preserved when the active project happens to equal the launch one. * * Pass through `RequestInit` unchanged; we only mutate the URL. */ export function projectFetch(input: string, init?: RequestInit): Promise { const cwd = getActiveProjectCwd(); if (!cwd) return fetch(input, init); const sep = input.includes("?") ? "&" : "?"; return fetch(`${input}${sep}project=${encodeURIComponent(cwd)}`, init); } /** Remove a project from the catalog (Catalog page "Forget" action). */ export function forgetProject(cwd: string): void { if (typeof localStorage === "undefined") return; try { const catalog = loadCatalog(); delete catalog[cwd]; localStorage.setItem(STORAGE_KEY, JSON.stringify(catalog)); } catch { // ignore } }