feat(studio): detect and list multiple projects in parent directories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-03-31 18:03:51 +00:00
committed by Miguel Ángel
co-authored by Claude Opus 4.6
parent ecb590d444
commit 25efa9d69a
2 changed files with 90 additions and 19 deletions
+43 -6
View File
@@ -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);
// ── 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[] = [];
// 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 project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
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<string | null> {
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 = {
+47 -13
View File
@@ -18,27 +18,36 @@ interface EditingFile {
content: string | null;
}
interface ProjectInfo {
id: string;
title?: string;
}
// ── Main App ──
export function StudioApp() {
const [projectId, setProjectId] = useState<string | null>(null);
const [projectList, setProjectList] = useState<ProjectInfo[]>([]);
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<Map<string, string>>(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() {
<div className="flex flex-col h-screen w-screen bg-neutral-950">
{/* Header bar */}
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
{/* Left: project name */}
{/* Left: project name / selector */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-neutral-400">{projectId}</span>
{projectList.length > 1 ? (
<select
value={projectId ?? ""}
onChange={(e) => handleSelectProject(e.target.value)}
className="text-[11px] font-medium text-neutral-300 bg-neutral-800 border border-neutral-700 rounded-md px-2 h-6 outline-none hover:border-neutral-600 focus:border-[#3CE6AC]/50 cursor-pointer"
>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.title ?? p.id}
</option>
))}
</select>
) : (
<span className="text-[11px] font-medium text-neutral-400">{projectId}</span>
)}
</div>
{/* Right: toolbar buttons */}
<div className="flex items-center gap-1.5">