Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string | null> {
try {
Expand All @@ -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 = {
Expand Down
60 changes: 47 additions & 13 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {})
Expand All @@ -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);
Expand Down Expand Up @@ -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">
Expand Down
Loading