diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index eb89fb4ac..571412926 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -3,11 +3,15 @@ * * Uses the shared studio API module from @hyperframes/core/studio-api, * providing a CLI-specific adapter for single-project, in-process rendering. + * + * Supports multi-project directories: when the target directory contains + * subdirectories with their own `index.html`, each is exposed as a separate + * project in the Studio UI. */ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; -import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, writeFileSync, statSync } from "node:fs"; import { resolve, join, basename } from "node:path"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; import { @@ -108,14 +112,47 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { const runtimePath = resolveRuntimePath(); const watcher = createProjectWatcher(projectDir); - // ── CLI adapter for the shared studio API ────────────────────────────── + // ── Multi-project discovery ──────────────────────────────────────────── + // Scan for sub-projects: immediate subdirectories containing index.html. + // Skips hidden dirs, node_modules, renders, and .thumbnails. + + const SKIP_DIRS = new Set(["node_modules", "renders", ".thumbnails"]); + + function discoverProjects(): ResolvedProject[] { + const projects: ResolvedProject[] = []; - const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId }; + // Root project (if index.html exists at the top level) + if (existsSync(join(projectDir, "index.html"))) { + projects.push({ id: projectId, dir: projectDir, title: projectId }); + } + + // Scan immediate subdirectories (1 level deep) + let entries: import("node:fs").Dirent[]; + try { + entries = readdirSync(projectDir, { withFileTypes: true }); + } catch { + return projects; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".")) continue; + if (SKIP_DIRS.has(entry.name)) continue; + const subDir = join(projectDir, entry.name); + if (existsSync(join(subDir, "index.html"))) { + projects.push({ id: entry.name, dir: subDir, title: entry.name }); + } + } + + return projects; + } + + // ── CLI adapter for the shared studio API ────────────────────────────── const adapter: StudioApiAdapter = { - listProjects: () => [project], + listProjects: () => discoverProjects(), - resolveProject: (id: string) => (id === projectId ? project : null), + resolveProject: (id: string) => discoverProjects().find((p) => p.id === id) ?? null, async bundle(dir: string): Promise { try { @@ -139,7 +176,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { runtimeUrl: "/api/runtime.js", - rendersDir: () => join(projectDir, "renders"), + rendersDir: (project) => join(project.dir, "renders"), startRender(opts): RenderJobState { const state: RenderJobState = { diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index dc79aada0..81c5c0936 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -18,27 +18,36 @@ interface EditingFile { content: string | null; } +interface ProjectInfo { + id: string; + title?: string; +} + // ── Main App ── export function StudioApp() { const [projectId, setProjectId] = useState(null); + const [projectList, setProjectList] = useState([]); const [resolving, setResolving] = useState(true); useMountEffect(() => { - const hashMatch = window.location.hash.match(/^#project\/([^/]+)/); - if (hashMatch) { - setProjectId(hashMatch[1]); - setResolving(false); - return; - } - // No hash — auto-select first available project fetch("/api/projects") .then((r) => r.json()) .then((data) => { - const first = (data.projects ?? [])[0]; - if (first) { - setProjectId(first.id); - window.location.hash = `#project/${first.id}`; + const projects: ProjectInfo[] = data.projects ?? []; + setProjectList(projects); + + // Honour hash-based project selection + const hashMatch = window.location.hash.match(/^#project\/([^/]+)/); + if (hashMatch && projects.some((p) => p.id === hashMatch[1])) { + setProjectId(hashMatch[1]); + } else { + // Auto-select first available project + const first = projects[0]; + if (first) { + setProjectId(first.id); + window.location.hash = `#project/${first.id}`; + } } }) .catch(() => {}) @@ -51,6 +60,17 @@ export function StudioApp() { const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); const renderQueue = useRenderQueue(projectId); + const handleSelectProject = useCallback((id: string) => { + setProjectId(id); + window.location.hash = `#project/${id}`; + // Reset editor and preview state when switching projects + setEditingFile(null); + setActiveCompPath(null); + setFileTree([]); + setCompIdToSrc(new Map()); + setRefreshKey((k) => k + 1); + }, []); + // Resizable and collapsible panel widths const [leftWidth, setLeftWidth] = useState(240); const [rightWidth, setRightWidth] = useState(400); @@ -450,9 +470,23 @@ export function StudioApp() {
{/* Header bar */}
- {/* Left: project name */} + {/* Left: project name / selector */}
- {projectId} + {projectList.length > 1 ? ( + + ) : ( + {projectId} + )}
{/* Right: toolbar buttons */}