mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
committed by
Miguel Ángel
co-authored by
Claude Opus 4.6
parent
ecb590d444
commit
25efa9d69a
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user