mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): full IDE-like file management (#147)
## Summary - **API**: POST (create), DELETE (delete), PATCH (rename/move), POST duplicate endpoints with null-byte sanitization - **FileTree**: right-click context menu with New File, New Folder, Rename, Duplicate, Delete - **Drag-and-drop**: move files between folders with visual feedback and subtree guard - **Inline editing**: rename/create inputs with filename validation - **Header actions**: quick New File / New Folder buttons in the FILES header Stacks on top of `feat/studio-code-quality`. ## Test plan - [x] Right-click file → Rename, Delete, Duplicate all work - [x] Right-click folder → New File, New Folder, Delete work - [x] Drag file from one folder to another - [x] Create file with invalid name (`../foo`, `a/b`) → rejected client-side - [x] Delete currently-edited file → editor clears - [x] Studio build succeeds
This commit is contained in:
@@ -1,36 +1,241 @@
|
||||
import type { Hono } from "hono";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
renameSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
|
||||
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// Read file content
|
||||
api.get("/projects/:id/files/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
const file = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, file) || !existsSync(file)) {
|
||||
return c.text("not found", 404);
|
||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the project and file path from the request, validating safety.
|
||||
* Returns null (and sends an error response) if anything is invalid.
|
||||
*/
|
||||
interface RouteContext {
|
||||
req: { param: (name: string) => string; path: string };
|
||||
json: (data: unknown, status?: number) => Response;
|
||||
}
|
||||
|
||||
async function resolveProjectFile(
|
||||
c: RouteContext,
|
||||
adapter: StudioApiAdapter,
|
||||
opts?: { mustExist?: boolean },
|
||||
) {
|
||||
const id = c.req.param("id");
|
||||
const project = await adapter.resolveProject(id);
|
||||
if (!project) {
|
||||
return { error: c.json({ error: "not found" }, 404) } as const;
|
||||
}
|
||||
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
if (filePath.includes("\0")) {
|
||||
return { error: c.json({ error: "forbidden" }, 403) } as const;
|
||||
}
|
||||
|
||||
const absPath = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, absPath)) {
|
||||
return { error: c.json({ error: "forbidden" }, 403) } as const;
|
||||
}
|
||||
|
||||
if (opts?.mustExist && !existsSync(absPath)) {
|
||||
return { error: c.json({ error: "not found" }, 404) } as const;
|
||||
}
|
||||
|
||||
return { project, filePath, absPath } as const;
|
||||
}
|
||||
|
||||
/** Ensure the parent directory of a path exists. */
|
||||
function ensureDir(filePath: string) {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a copy name: foo.html → foo (copy).html → foo (copy 2).html
|
||||
*/
|
||||
function generateCopyPath(projectDir: string, originalPath: string): string {
|
||||
const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
|
||||
const base = ext ? originalPath.slice(0, -ext.length) : originalPath;
|
||||
|
||||
// If already a copy, increment the number
|
||||
const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/);
|
||||
const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
|
||||
let num = copyMatch ? (copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2) : 1;
|
||||
|
||||
let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
|
||||
while (existsSync(resolve(projectDir, candidate))) {
|
||||
num++;
|
||||
candidate = `${cleanBase} (copy ${num})${ext}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory recursively and return all file paths matching a filter.
|
||||
*/
|
||||
function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders")
|
||||
continue;
|
||||
results.push(...walkFiles(full, filter));
|
||||
} else if (filter(entry.name)) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a rename, update all references to the old path in project files.
|
||||
* Scans HTML, CSS, JS, and JSON files for the old filename/path and replaces.
|
||||
*/
|
||||
function updateReferences(projectDir: string, oldPath: string, newPath: string): number {
|
||||
const textFiles = walkFiles(projectDir, (name) =>
|
||||
/\.(html|css|js|jsx|ts|tsx|json|mjs|cjs|md|mdx)$/i.test(name),
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const file of textFiles) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
return c.json({ filename: filePath, content });
|
||||
|
||||
// Only replace full relative paths — never bare filenames, which can
|
||||
// corrupt unrelated content (e.g. "logo.png" inside "my-logo.png").
|
||||
if (!content.includes(oldPath)) continue;
|
||||
|
||||
const updated = content.split(oldPath).join(newPath);
|
||||
if (updated !== content) {
|
||||
writeFileSync(file, updated, "utf-8");
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
return updatedCount;
|
||||
}
|
||||
|
||||
// ── Route registration ──────────────────────────────────────────────────────
|
||||
|
||||
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// ── Read ──
|
||||
|
||||
api.get("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const content = readFileSync(res.absPath, "utf-8");
|
||||
return c.json({ filename: res.filePath, content });
|
||||
});
|
||||
|
||||
// Write file content
|
||||
// ── Write (overwrite) ──
|
||||
|
||||
api.put("/projects/:id/files/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
const file = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, file)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
const dir = dirname(file);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const res = await resolveProjectFile(c, adapter);
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
ensureDir(res.absPath);
|
||||
const body = await c.req.text();
|
||||
writeFileSync(file, body, "utf-8");
|
||||
writeFileSync(res.absPath, body, "utf-8");
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Create (fail if exists) ──
|
||||
|
||||
api.post("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter);
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
if (existsSync(res.absPath)) {
|
||||
return c.json({ error: "already exists" }, 409);
|
||||
}
|
||||
|
||||
ensureDir(res.absPath);
|
||||
const body = await c.req.text().catch(() => "");
|
||||
writeFileSync(res.absPath, body, "utf-8");
|
||||
|
||||
return c.json({ ok: true, path: res.filePath }, 201);
|
||||
});
|
||||
|
||||
// ── Delete ──
|
||||
|
||||
api.delete("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const stat = statSync(res.absPath);
|
||||
if (stat.isDirectory()) {
|
||||
rmSync(res.absPath, { recursive: true });
|
||||
} else {
|
||||
unlinkSync(res.absPath);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Rename / Move ──
|
||||
|
||||
api.patch("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const body = (await c.req.json()) as { newPath?: string };
|
||||
if (!body.newPath || body.newPath.includes("\0")) {
|
||||
return c.json({ error: "newPath required" }, 400);
|
||||
}
|
||||
|
||||
const newAbs = resolve(res.project.dir, body.newPath);
|
||||
if (!isSafePath(res.project.dir, newAbs)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
if (existsSync(newAbs)) {
|
||||
return c.json({ error: "already exists" }, 409);
|
||||
}
|
||||
|
||||
ensureDir(newAbs);
|
||||
renameSync(res.absPath, newAbs);
|
||||
|
||||
// Update references to the old path across all project files
|
||||
const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
|
||||
|
||||
return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
|
||||
});
|
||||
|
||||
// ── Duplicate ──
|
||||
|
||||
api.post("/projects/:id/duplicate-file", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const body = (await c.req.json()) as { path: string };
|
||||
if (!body.path || body.path.includes("\0")) {
|
||||
return c.json({ error: "path required" }, 400);
|
||||
}
|
||||
|
||||
const srcAbs = resolve(project.dir, body.path);
|
||||
if (!isSafePath(project.dir, srcAbs) || !existsSync(srcAbs)) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
const copyPath = generateCopyPath(project.dir, body.path);
|
||||
const destAbs = resolve(project.dir, copyPath);
|
||||
if (!isSafePath(project.dir, destAbs)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
|
||||
ensureDir(destAbs);
|
||||
writeFileSync(destAbs, readFileSync(srcAbs));
|
||||
|
||||
return c.json({ ok: true, path: copyPath }, 201);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user