From 2b6ca3fd6cd3da280766a6c6942024e673f5d30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 02:09:40 +0200 Subject: [PATCH 1/9] feat(studio): add full IDE-like file management (CRUD, rename, duplicate, drag-and-drop) API: Add POST (create), DELETE, PATCH (rename/move), and duplicate-file endpoints to the studio files route, with path safety checks and conflict detection. UI: Rewrite FileTree with right-click context menu (New File, New Folder, Rename, Duplicate, Delete), inline rename/create inputs, drag-and-drop file moves between folders, delete confirmation prompt, and a FILES header bar with quick-action buttons. Wire all operations through App.tsx handlers that call the new API endpoints and refresh the file tree. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 101 ++- packages/studio/src/App.tsx | 115 +++ .../studio/src/components/editor/FileTree.tsx | 773 ++++++++++++++++-- .../src/components/sidebar/LeftSidebar.tsx | 20 +- 4 files changed, 959 insertions(+), 50 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 8931790b9..89e68b6af 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -1,5 +1,14 @@ import type { Hono } from "hono"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + unlinkSync, + rmSync, + statSync, + renameSync, +} from "node:fs"; import { resolve, dirname } from "node:path"; import type { StudioApiAdapter } from "../types.js"; import { isSafePath } from "../helpers/safePath.js"; @@ -33,4 +42,94 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { writeFileSync(file, body, "utf-8"); return c.json({ ok: true }); }); + + // Create a new file (empty or with content) + api.post("/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); + if (existsSync(file)) return c.json({ error: "already exists" }, 409); + const dir = dirname(file); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const body = await c.req.text().catch(() => ""); + writeFileSync(file, body, "utf-8"); + return c.json({ ok: true, path: filePath }, 201); + }); + + // Delete a file or directory + api.delete("/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.json({ error: "not found" }, 404); + } + const stat = statSync(file); + if (stat.isDirectory()) { + rmSync(file, { recursive: true }); + } else { + unlinkSync(file); + } + return c.json({ ok: true }); + }); + + // Rename / move a file or directory + api.patch("/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.json({ error: "not found" }, 404); + } + const body = (await c.req.json()) as { newPath?: string }; + if (!body.newPath) return c.json({ error: "newPath required" }, 400); + const newFile = resolve(project.dir, body.newPath); + if (!isSafePath(project.dir, newFile)) return c.json({ error: "forbidden" }, 403); + if (existsSync(newFile)) return c.json({ error: "already exists" }, 409); + const newDir = dirname(newFile); + if (!existsSync(newDir)) mkdirSync(newDir, { recursive: true }); + renameSync(file, newFile); + return c.json({ ok: true, path: body.newPath }); + }); + + // Duplicate a file + 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) return c.json({ error: "path required" }, 400); + const file = resolve(project.dir, body.path); + if (!isSafePath(project.dir, file) || !existsSync(file)) { + return c.json({ error: "not found" }, 404); + } + // Generate copy name: foo.html -> foo (copy).html, foo (copy).html -> foo (copy 2).html + const ext = body.path.includes(".") ? "." + body.path.split(".").pop() : ""; + const base = ext ? body.path.slice(0, -ext.length) : body.path; + let copyNum = 1; + const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/); + let copyPath: string; + if (copyMatch) { + const baseWithoutCopy = base.slice(0, -copyMatch[0].length); + copyNum = copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2; + copyPath = `${baseWithoutCopy} (copy ${copyNum})${ext}`; + } else { + copyPath = `${base} (copy)${ext}`; + } + while (existsSync(resolve(project.dir, copyPath))) { + copyNum++; + const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base; + copyPath = `${cleanBase} (copy ${copyNum})${ext}`; + } + const dest = resolve(project.dir, copyPath); + if (!isSafePath(project.dir, dest)) return c.json({ error: "forbidden" }, 403); + const content = readFileSync(file); + const destDir = dirname(dest); + if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }); + writeFileSync(dest, content); + return c.json({ ok: true, path: copyPath }, 201); + }); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4728395fd..5a7c092c9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -246,6 +246,115 @@ export function StudioApp() { }, 600); }, []); + // ── File Management Handlers ── + + const refreshFileTree = useCallback(async () => { + const pid = projectIdRef.current; + if (!pid) return; + const res = await fetch(`/api/projects/${pid}`); + const data = await res.json(); + if (data.files) setFileTree(data.files); + }, []); + + const handleCreateFile = useCallback( + async (path: string) => { + const pid = projectIdRef.current; + if (!pid) return; + let content = ""; + if (path.endsWith(".html")) { + content = + '\n\n\n \n\n\n\n\n\n'; + } + const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: content, + }); + if (res.ok) { + await refreshFileTree(); + handleFileSelect(path); + } + }, + [refreshFileTree, handleFileSelect], + ); + + const handleCreateFolder = useCallback( + async (path: string) => { + const pid = projectIdRef.current; + if (!pid) return; + // Create a .gitkeep inside the folder so it appears in the tree + const res = await fetch( + `/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`, + { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "", + }, + ); + if (res.ok) await refreshFileTree(); + }, + [refreshFileTree], + ); + + const handleDeleteFile = useCallback( + async (path: string) => { + const pid = projectIdRef.current; + if (!pid) return; + const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, { + method: "DELETE", + }); + if (res.ok) { + if (editingFile?.path === path) setEditingFile(null); + await refreshFileTree(); + } + }, + [editingFile, refreshFileTree], + ); + + const handleRenameFile = useCallback( + async (oldPath: string, newPath: string) => { + const pid = projectIdRef.current; + if (!pid) return; + const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newPath }), + }); + if (res.ok) { + if (editingFile?.path === oldPath) { + handleFileSelect(newPath); + } + await refreshFileTree(); + } + }, + [editingFile, refreshFileTree, handleFileSelect], + ); + + const handleDuplicateFile = useCallback( + async (path: string) => { + const pid = projectIdRef.current; + if (!pid) return; + const res = await fetch(`/api/projects/${pid}/duplicate-file`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (res.ok) { + const data = await res.json(); + await refreshFileTree(); + if (data.path) handleFileSelect(data.path); + } + }, + [refreshFileTree, handleFileSelect], + ); + + const handleMoveFile = useCallback( + async (oldPath: string, newPath: string) => { + await handleRenameFile(oldPath, newPath); + }, + [handleRenameFile], + ); + const handleLint = useCallback(async () => { const pid = projectIdRef.current; if (!pid) return; @@ -433,6 +542,12 @@ export function StudioApp() { fileTree={fileTree} editingFile={editingFile} onSelectFile={handleFileSelect} + onCreateFile={handleCreateFile} + onCreateFolder={handleCreateFolder} + onDeleteFile={handleDeleteFile} + onRenameFile={handleRenameFile} + onDuplicateFile={handleDuplicateFile} + onMoveFile={handleMoveFile} codeChildren={ editingFile ? ( isMediaFile(editingFile.path) ? ( diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 9bc2c40d5..740ad7e25 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback, useMemo } from "react"; +import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react"; import { FileHtml, FileCss, @@ -17,18 +17,62 @@ import { Waveform, TextAa, Image as PhImage, + PencilSimple, + Copy, + Trash, + Plus, + FolderSimplePlus, + FilePlus, + FolderSimple, } from "@phosphor-icons/react"; import { ChevronDown, ChevronRight } from "../../icons/SystemIcons"; -interface FileTreeProps { +// ── Types ── + +export interface FileTreeProps { files: string[]; activeFile: string | null; onSelectFile: (path: string) => void; + onCreateFile?: (path: string) => void; + onCreateFolder?: (path: string) => void; + onDeleteFile?: (path: string) => void; + onRenameFile?: (oldPath: string, newPath: string) => void; + onDuplicateFile?: (path: string) => void; + onMoveFile?: (oldPath: string, newPath: string) => void; +} + +interface TreeNode { + name: string; + fullPath: string; + children: Map; + isFile: boolean; +} + +interface ContextMenuState { + x: number; + y: number; + targetPath: string; + targetIsFolder: boolean; } +interface InlineInputState { + /** Parent folder path (empty string for root) */ + parentPath: string; + /** "file" or "folder" creation, or "rename" */ + mode: "new-file" | "new-folder" | "rename"; + /** For rename mode, the original full path */ + originalPath?: string; + /** For rename mode, the original name */ + originalName?: string; +} + +// ── Constants ── + const SZ = 14; const W = "duotone" as const; +// ── FileIcon ── + function FileIcon({ path }: { path: string }) { const ext = path.split(".").pop()?.toLowerCase() ?? ""; const c = "flex-shrink-0"; @@ -59,12 +103,7 @@ function FileIcon({ path }: { path: string }) { return ; } -interface TreeNode { - name: string; - fullPath: string; - children: Map; - isFile: boolean; -} +// ── Tree Helpers ── function buildTree(files: string[]): TreeNode { const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false }; @@ -102,83 +141,474 @@ function sortChildren(children: Map): TreeNode[] { }); } +function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean { + if (!activeFile) return false; + if (node.fullPath === activeFile) return true; + for (const child of node.children.values()) { + if (isActiveInSubtree(child, activeFile)) return true; + } + return false; +} + +// ── Context Menu Component ── + +function ContextMenu({ + state, + onClose, + onNewFile, + onNewFolder, + onRename, + onDuplicate, + onDelete, +}: { + state: ContextMenuState; + onClose: () => void; + onNewFile: (parentPath: string) => void; + onNewFolder: (parentPath: string) => void; + onRename: (path: string) => void; + onDuplicate: (path: string) => void; + onDelete: (path: string) => void; +}) { + const menuRef = useRef(null); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [onClose]); + + // Adjust position so menu doesn't overflow viewport + const adjustedX = Math.min(state.x, window.innerWidth - 180); + const adjustedY = Math.min(state.y, window.innerHeight - 200); + + const parentPath = state.targetIsFolder + ? state.targetPath + : state.targetPath.includes("/") + ? state.targetPath.slice(0, state.targetPath.lastIndexOf("/")) + : ""; + + return ( +
+ {state.targetIsFolder && ( + <> + + +
+ + )} + {!state.targetIsFolder && ( + <> + +
+ + )} + + {!state.targetIsFolder && ( + + )} +
+ +
+ ); +} + +// ── Inline Input (for new file/folder/rename) ── + +function InlineInput({ + defaultValue, + depth, + isFolder, + onCommit, + onCancel, +}: { + defaultValue: string; + depth: number; + isFolder: boolean; + onCommit: (value: string) => void; + onCancel: () => void; +}) { + const inputRef = useRef(null); + const [value, setValue] = useState(defaultValue); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + // Select just the filename (not extension) for rename + if (defaultValue && defaultValue.includes(".")) { + const dotIdx = defaultValue.lastIndexOf("."); + el.setSelectionRange(0, dotIdx); + } else { + el.select(); + } + }, [defaultValue]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const trimmed = value.trim(); + if (trimmed) onCommit(trimmed); + else onCancel(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + const handleBlur = () => { + const trimmed = value.trim(); + if (trimmed && trimmed !== defaultValue) onCommit(trimmed); + else onCancel(); + }; + + return ( +
+ {isFolder ? ( + + ) : ( + + )} + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]" + spellCheck={false} + /> +
+ ); +} + +// ── Delete Confirmation ── + +function DeleteConfirm({ + name, + onConfirm, + onCancel, +}: { + name: string; + onConfirm: () => void; + onCancel: () => void; +}) { + const ref = useRef(null); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onCancel(); + }; + document.addEventListener("keydown", handleEscape); + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("keydown", handleEscape); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [onCancel]); + + return ( +
+

+ Delete {name}? +

+
+ + +
+
+ ); +} + +// ── TreeFolder ── + function TreeFolder({ node, depth, activeFile, onSelectFile, defaultOpen, + onContextMenu, + inlineInput, + onDragStart, + onDragOver, + onDrop, + onDragLeave, + dragOverFolder, }: { node: TreeNode; depth: number; activeFile: string | null; onSelectFile: (path: string) => void; defaultOpen: boolean; + onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void; + inlineInput: InlineInputState | null; + onDragStart: (e: React.DragEvent, path: string) => void; + onDragOver: (e: React.DragEvent, folderPath: string) => void; + onDrop: (e: React.DragEvent, folderPath: string) => void; + onDragLeave: () => void; + dragOverFolder: string | null; }) { const [isOpen, setIsOpen] = useState(defaultOpen); const toggle = useCallback(() => setIsOpen((v) => !v), []); const children = sortChildren(node.children); const Chevron = isOpen ? ChevronDown : ChevronRight; + const isDragOver = dragOverFolder === node.fullPath; + const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath; + + if (isRenaming) { + return ( + { + (inlineInput as InlineInputState & { onCommit?: (name: string) => void }).onCommit?.( + name, + ); + }} + onCancel={() => { + (inlineInput as InlineInputState & { onCancel?: () => void }).onCancel?.(); + }} + /> + ); + } return ( <> - {isOpen && - children.map((child) => - child.isFile && child.children.size === 0 ? ( - - ) : child.children.size > 0 ? ( - - ) : ( - - ), - )} + {isOpen && ( + <> + {/* Inline input for new file/folder inside this folder */} + {inlineInput && + (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && + inlineInput.parentPath === node.fullPath && ( + { + // onCommit is handled by the parent FileTree component + // via the inlineInputCommit callback + ( + inlineInput as InlineInputState & { onCommit?: (name: string) => void } + ).onCommit?.(name); + }} + onCancel={() => { + (inlineInput as InlineInputState & { onCancel?: () => void }).onCancel?.(); + }} + /> + )} + {children.map((child) => + child.isFile && child.children.size === 0 ? ( + + ) : child.children.size > 0 ? ( + + ) : ( + + ), + )} + + )} ); } +// ── TreeFile ── + function TreeFile({ node, depth, activeFile, onSelectFile, + onContextMenu, + inlineInput, + onDragStart, }: { node: TreeNode; depth: number; activeFile: string | null; onSelectFile: (path: string) => void; + onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void; + inlineInput: InlineInputState | null; + onDragStart: (e: React.DragEvent, path: string) => void; }) { const isActive = node.fullPath === activeFile; + const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath; + + if (isRenaming) { + return ( + { + (inlineInput as InlineInputState & { onCommit?: (name: string) => void }).onCommit?.( + name, + ); + }} + onCancel={() => { + (inlineInput as InlineInputState & { onCancel?: () => void }).onCancel?.(); + }} + /> + ); + } return ( + +
+
+ )} + +
+ {/* Root-level inline input for new file/folder */} + {inlineInput && + (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && + inlineInput.parentPath === "" && ( + inlineInput.onCommit?.(name)} + onCancel={() => inlineInput.onCancel?.()} + /> + )} {children.map((child) => child.isFile && child.children.size === 0 ? ( ) : ( ), )}
+ + {/* Delete confirmation overlay */} + {deleteTarget && ( +
+ +
+ )} + + {/* Context menu */} + {contextMenu && ( + + )}
); }); diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index a4665d185..7f30c27f5 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -26,6 +26,12 @@ interface LeftSidebarProps { fileTree?: string[]; editingFile?: { path: string; content: string | null } | null; onSelectFile?: (path: string) => void; + onCreateFile?: (path: string) => void; + onCreateFolder?: (path: string) => void; + onDeleteFile?: (path: string) => void; + onRenameFile?: (oldPath: string, newPath: string) => void; + onDuplicateFile?: (path: string) => void; + onMoveFile?: (oldPath: string, newPath: string) => void; codeChildren?: ReactNode; onLint?: () => void; linting?: boolean; @@ -42,6 +48,12 @@ export const LeftSidebar = memo(function LeftSidebar({ fileTree: fileProp, editingFile, onSelectFile, + onCreateFile, + onCreateFolder, + onDeleteFile, + onRenameFile, + onDuplicateFile, + onMoveFile, codeChildren, onLint, linting, @@ -127,11 +139,17 @@ export const LeftSidebar = memo(function LeftSidebar({ {tab === "code" && (
{(fileProp?.length ?? 0) > 0 && ( -
+
{})} + onCreateFile={onCreateFile} + onCreateFolder={onCreateFolder} + onDeleteFile={onDeleteFile} + onRenameFile={onRenameFile} + onDuplicateFile={onDuplicateFile} + onMoveFile={onMoveFile} />
)} From 7232313d77564ac080c671210a8a0df4670d2ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 02:16:12 +0200 Subject: [PATCH 2/9] fix(studio): address code review findings for file management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix InlineInput double-fire on Enter+blur with committed ref guard - Add null byte sanitization via extractFilePath helper in API routes - Fix stale editingFile closure — use editingPathRef instead - Add client-side filename validation (reject / \ ..) - Prevent drag-and-drop folder-into-own-subtree - Remove trivial handleMoveFile wrapper Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 21 ++++++++++++++----- packages/studio/src/App.tsx | 15 +++++-------- .../studio/src/components/editor/FileTree.tsx | 16 ++++++++++---- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 89e68b6af..9b110b062 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -13,12 +13,19 @@ import { resolve, dirname } from "node:path"; import type { StudioApiAdapter } from "../types.js"; import { isSafePath } from "../helpers/safePath.js"; +function extractFilePath(reqPath: string, projectId: string): string | null { + const filePath = decodeURIComponent(reqPath.replace(`/projects/${projectId}/files/`, "")); + if (filePath.includes("\0")) return null; + return filePath; +} + 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 filePath = extractFilePath(c.req.path, project.id); + if (!filePath) return c.json({ error: "forbidden" }, 403); const file = resolve(project.dir, filePath); if (!isSafePath(project.dir, file) || !existsSync(file)) { return c.text("not found", 404); @@ -31,7 +38,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { 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 filePath = extractFilePath(c.req.path, project.id); + if (!filePath) return c.json({ error: "forbidden" }, 403); const file = resolve(project.dir, filePath); if (!isSafePath(project.dir, file)) { return c.json({ error: "forbidden" }, 403); @@ -47,7 +55,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { api.post("/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 filePath = extractFilePath(c.req.path, project.id); + if (!filePath) return c.json({ error: "forbidden" }, 403); const file = resolve(project.dir, filePath); if (!isSafePath(project.dir, file)) return c.json({ error: "forbidden" }, 403); if (existsSync(file)) return c.json({ error: "already exists" }, 409); @@ -62,7 +71,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { api.delete("/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 filePath = extractFilePath(c.req.path, project.id); + if (!filePath) return c.json({ error: "forbidden" }, 403); const file = resolve(project.dir, filePath); if (!isSafePath(project.dir, file) || !existsSync(file)) { return c.json({ error: "not found" }, 404); @@ -80,7 +90,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { api.patch("/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 filePath = extractFilePath(c.req.path, project.id); + if (!filePath) return c.json({ error: "forbidden" }, 403); const file = resolve(project.dir, filePath); if (!isSafePath(project.dir, file) || !existsSync(file)) { return c.json({ error: "not found" }, 404); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5a7c092c9..c9b854f89 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -304,11 +304,11 @@ export function StudioApp() { method: "DELETE", }); if (res.ok) { - if (editingFile?.path === path) setEditingFile(null); + if (editingPathRef.current === path) setEditingFile(null); await refreshFileTree(); } }, - [editingFile, refreshFileTree], + [refreshFileTree], ); const handleRenameFile = useCallback( @@ -321,13 +321,13 @@ export function StudioApp() { body: JSON.stringify({ newPath }), }); if (res.ok) { - if (editingFile?.path === oldPath) { + if (editingPathRef.current === oldPath) { handleFileSelect(newPath); } await refreshFileTree(); } }, - [editingFile, refreshFileTree, handleFileSelect], + [refreshFileTree, handleFileSelect], ); const handleDuplicateFile = useCallback( @@ -348,12 +348,7 @@ export function StudioApp() { [refreshFileTree, handleFileSelect], ); - const handleMoveFile = useCallback( - async (oldPath: string, newPath: string) => { - await handleRenameFile(oldPath, newPath); - }, - [handleRenameFile], - ); + const handleMoveFile = handleRenameFile; const handleLint = useCallback(async () => { const pid = projectIdRef.current; diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 740ad7e25..bf9583730 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -298,6 +298,7 @@ function InlineInput({ onCancel: () => void; }) { const inputRef = useRef(null); + const committedRef = useRef(false); const [value, setValue] = useState(defaultValue); // eslint-disable-next-line no-restricted-syntax @@ -314,11 +315,17 @@ function InlineInput({ } }, [defaultValue]); + const commit = (name: string) => { + if (committedRef.current) return; + committedRef.current = true; + onCommit(name); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); const trimmed = value.trim(); - if (trimmed) onCommit(trimmed); + if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed); else onCancel(); } else if (e.key === "Escape") { e.preventDefault(); @@ -328,7 +335,8 @@ function InlineInput({ const handleBlur = () => { const trimmed = value.trim(); - if (trimmed && trimmed !== defaultValue) onCommit(trimmed); + if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) + commit(trimmed); else onCancel(); }; @@ -779,8 +787,8 @@ export const FileTree = memo(function FileTree({ ? sourcePath.slice(sourcePath.lastIndexOf("/") + 1) : sourcePath; const newPath = folderPath ? `${folderPath}/${fileName}` : fileName; - // Don't move to same location - if (newPath !== sourcePath) { + // Don't move to same location or into own subtree + if (newPath !== sourcePath && !folderPath.startsWith(sourcePath + "/")) { onMoveFile(sourcePath, newPath); } setDragOverFolder(null); From 22dbbb15ea8736411a941cc450736fba5002a27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 03:54:13 +0200 Subject: [PATCH 3/9] refactor(studio): use useMountEffect for project init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore useMountEffect for the project hash resolution — functionally identical to useEffect(fn, []) but consistent with codebase conventions. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/studio/src/App.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index c9b854f89..c17f22f14 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -24,8 +24,7 @@ export function StudioApp() { const [projectId, setProjectId] = useState(null); const [resolving, setResolving] = useState(true); - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { + useMountEffect(() => { const hashMatch = window.location.hash.match(/^#project\/([^/]+)/); if (hashMatch) { setProjectId(hashMatch[1]); @@ -44,7 +43,7 @@ export function StudioApp() { }) .catch(() => {}) .finally(() => setResolving(false)); - }, []); + }); const [editingFile, setEditingFile] = useState(null); const [activeCompPath, setActiveCompPath] = useState(null); From 0b50e7ec34aa5b16fbaacff1ea18bb64af8aaa30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 18:00:53 +0200 Subject: [PATCH 4/9] fix(studio): show correct file icon during rename in file tree InlineInput was always showing a generic File icon. Now uses FileIcon with the current input value so the icon updates live as you type. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/studio/src/components/editor/FileTree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index bf9583730..ce7cb5cc4 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -348,7 +348,7 @@ function InlineInput({ {isFolder ? ( ) : ( - + )} Date: Tue, 31 Mar 2026 18:03:57 +0200 Subject: [PATCH 5/9] refactor(core): improve readability of file management routes Extract shared resolveProjectFile() helper to eliminate repeated project/path/safety boilerplate. Extract ensureDir() and generateCopyPath() helpers. Add section headers and spacing. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 232 +++++++++++-------- 1 file changed, 138 insertions(+), 94 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 9b110b062..57813f9ff 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -1,4 +1,5 @@ import type { Hono } from "hono"; +import type { Context } from "hono"; import { existsSync, readFileSync, @@ -13,134 +14,177 @@ import { resolve, dirname } from "node:path"; import type { StudioApiAdapter } from "../types.js"; import { isSafePath } from "../helpers/safePath.js"; -function extractFilePath(reqPath: string, projectId: string): string | null { - const filePath = decodeURIComponent(reqPath.replace(`/projects/${projectId}/files/`, "")); - if (filePath.includes("\0")) return null; - return filePath; +// ── Shared helpers ────────────────────────────────────────────────────────── + +/** + * Resolve the project and file path from the request, validating safety. + * Returns null (and sends an error response) if anything is invalid. + */ +async function resolveProjectFile( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + c: Context, + adapter: StudioApiAdapter, + opts?: { mustExist?: boolean }, +) { + const id = c.req.param("id") as string; + 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; } +// ── Route registration ────────────────────────────────────────────────────── + export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { - // Read file content + // ── Read ── + 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 = extractFilePath(c.req.path, project.id); - if (!filePath) return c.json({ error: "forbidden" }, 403); - const file = resolve(project.dir, filePath); - if (!isSafePath(project.dir, file) || !existsSync(file)) { - return c.text("not found", 404); - } - const content = readFileSync(file, "utf-8"); - return c.json({ filename: filePath, content }); + 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 = extractFilePath(c.req.path, project.id); - if (!filePath) return c.json({ error: "forbidden" }, 403); - 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 a new file (empty or with content) + // ── Create (fail if exists) ── + api.post("/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 = extractFilePath(c.req.path, project.id); - if (!filePath) return c.json({ error: "forbidden" }, 403); - const file = resolve(project.dir, filePath); - if (!isSafePath(project.dir, file)) return c.json({ error: "forbidden" }, 403); - if (existsSync(file)) return c.json({ error: "already exists" }, 409); - const dir = dirname(file); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + 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(file, body, "utf-8"); - return c.json({ ok: true, path: filePath }, 201); + writeFileSync(res.absPath, body, "utf-8"); + + return c.json({ ok: true, path: res.filePath }, 201); }); - // Delete a file or directory + // ── Delete ── + api.delete("/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 = extractFilePath(c.req.path, project.id); - if (!filePath) return c.json({ error: "forbidden" }, 403); - const file = resolve(project.dir, filePath); - if (!isSafePath(project.dir, file) || !existsSync(file)) { - return c.json({ error: "not found" }, 404); - } - const stat = statSync(file); + const res = await resolveProjectFile(c, adapter, { mustExist: true }); + if ("error" in res) return res.error; + + const stat = statSync(res.absPath); if (stat.isDirectory()) { - rmSync(file, { recursive: true }); + rmSync(res.absPath, { recursive: true }); } else { - unlinkSync(file); + unlinkSync(res.absPath); } + return c.json({ ok: true }); }); - // Rename / move a file or directory + // ── Rename / Move ── + api.patch("/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 = extractFilePath(c.req.path, project.id); - if (!filePath) return c.json({ error: "forbidden" }, 403); - const file = resolve(project.dir, filePath); - if (!isSafePath(project.dir, file) || !existsSync(file)) { - return c.json({ error: "not found" }, 404); - } + 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) return c.json({ error: "newPath required" }, 400); - const newFile = resolve(project.dir, body.newPath); - if (!isSafePath(project.dir, newFile)) return c.json({ error: "forbidden" }, 403); - if (existsSync(newFile)) return c.json({ error: "already exists" }, 409); - const newDir = dirname(newFile); - if (!existsSync(newDir)) mkdirSync(newDir, { recursive: true }); - renameSync(file, newFile); + if (!body.newPath) { + 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); + return c.json({ ok: true, path: body.newPath }); }); - // Duplicate a file + // ── 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) return c.json({ error: "path required" }, 400); - const file = resolve(project.dir, body.path); - if (!isSafePath(project.dir, file) || !existsSync(file)) { - return c.json({ error: "not found" }, 404); + if (!body.path) { + return c.json({ error: "path required" }, 400); } - // Generate copy name: foo.html -> foo (copy).html, foo (copy).html -> foo (copy 2).html - const ext = body.path.includes(".") ? "." + body.path.split(".").pop() : ""; - const base = ext ? body.path.slice(0, -ext.length) : body.path; - let copyNum = 1; - const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/); - let copyPath: string; - if (copyMatch) { - const baseWithoutCopy = base.slice(0, -copyMatch[0].length); - copyNum = copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2; - copyPath = `${baseWithoutCopy} (copy ${copyNum})${ext}`; - } else { - copyPath = `${base} (copy)${ext}`; + + const srcAbs = resolve(project.dir, body.path); + if (!isSafePath(project.dir, srcAbs) || !existsSync(srcAbs)) { + return c.json({ error: "not found" }, 404); } - while (existsSync(resolve(project.dir, copyPath))) { - copyNum++; - const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base; - copyPath = `${cleanBase} (copy ${copyNum})${ext}`; + + const copyPath = generateCopyPath(project.dir, body.path); + const destAbs = resolve(project.dir, copyPath); + if (!isSafePath(project.dir, destAbs)) { + return c.json({ error: "forbidden" }, 403); } - const dest = resolve(project.dir, copyPath); - if (!isSafePath(project.dir, dest)) return c.json({ error: "forbidden" }, 403); - const content = readFileSync(file); - const destDir = dirname(dest); - if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }); - writeFileSync(dest, content); + + ensureDir(destAbs); + writeFileSync(destAbs, readFileSync(srcAbs)); + return c.json({ ok: true, path: copyPath }, 201); }); } From ef0284a80e92b837d812468b3092bc4d5de8ba7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 18:27:04 +0200 Subject: [PATCH 6/9] fix(core): replace Context with minimal RouteContext interface Avoids eslint-disable for no-explicit-any by defining a narrow interface with just the req/json methods the helper actually uses. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 57813f9ff..ff98d6d07 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -1,5 +1,4 @@ import type { Hono } from "hono"; -import type { Context } from "hono"; import { existsSync, readFileSync, @@ -20,13 +19,17 @@ import { isSafePath } from "../helpers/safePath.js"; * 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( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - c: Context, + c: RouteContext, adapter: StudioApiAdapter, opts?: { mustExist?: boolean }, ) { - const id = c.req.param("id") as string; + const id = c.req.param("id"); const project = await adapter.resolveProject(id); if (!project) { return { error: c.json({ error: "not found" }, 404) } as const; From 3ac032326bce7056a64138edd76641af680e5318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 18:47:49 +0200 Subject: [PATCH 7/9] feat(studio): update file references across project on rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a file is renamed, scan all HTML/CSS/JS/JSON files in the project and replace references to the old path with the new one — similar to VSCode's Rename Symbol. Updates both full paths (assets/music.mp3) and bare filenames (music.mp3). Preview also refreshes after rename. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 63 +++++++++++++++++++- packages/studio/src/App.tsx | 2 + 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index ff98d6d07..b630405f0 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -8,8 +8,9 @@ import { rmSync, statSync, renameSync, + readdirSync, } from "node:fs"; -import { resolve, dirname } from "node:path"; +import { resolve, dirname, join } from "node:path"; import type { StudioApiAdapter } from "../types.js"; import { isSafePath } from "../helpers/safePath.js"; @@ -79,6 +80,61 @@ function generateCopyPath(projectDir: string, originalPath: string): string { 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"); + + // Replace both the full relative path and just the filename + // e.g. "assets/music.mp3" → "assets/background.mp3" + // and "music.mp3" → "background.mp3" (for same-directory refs) + const oldName = oldPath.split("/").pop() ?? oldPath; + const newName = newPath.split("/").pop() ?? newPath; + + let updated = content; + if (content.includes(oldPath)) { + updated = updated.split(oldPath).join(newPath); + } + // Only do filename-level replacement if names actually changed + // and the filename isn't too generic (avoid replacing "a.js" inside "data.js") + if (oldName !== newName && oldName.length > 2 && content.includes(oldName)) { + updated = updated.split(oldName).join(newName); + } + + if (updated !== content) { + writeFileSync(file, updated, "utf-8"); + updatedCount++; + } + } + return updatedCount; +} + // ── Route registration ────────────────────────────────────────────────────── export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { @@ -160,7 +216,10 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { ensureDir(newAbs); renameSync(res.absPath, newAbs); - return c.json({ ok: true, path: body.newPath }); + // 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 ── diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index c17f22f14..dc858b135 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -324,6 +324,8 @@ export function StudioApp() { handleFileSelect(newPath); } await refreshFileTree(); + // Refresh preview — references in compositions may have been updated + setRefreshKey((k) => k + 1); } }, [refreshFileTree, handleFileSelect], From 366e149c55d6944324efb3770898dbb3b6383ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 18:50:58 +0200 Subject: [PATCH 8/9] =?UTF-8?q?feat(studio):=20right-click=20context=20men?= =?UTF-8?q?u=20on=20assets=20=E2=80=94=20rename=20and=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assets tab now has a right-click context menu with Copy path, Rename, and Delete options. Rename shows an inline input and updates references across the project via the existing rename endpoint. Delete removes the file. Both reuse the existing file management handlers from App.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/sidebar/AssetsTab.tsx | 189 ++++++++++++++---- .../src/components/sidebar/LeftSidebar.tsx | 8 +- 2 files changed, 158 insertions(+), 39 deletions(-) diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index 9207a9a3c..2e7ffaf35 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -6,6 +6,8 @@ interface AssetsTabProps { projectId: string; assets: string[]; onImport?: (files: FileList) => void; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; } /** Inline thumbnail content — rendered inside the container div in AssetCard. */ @@ -82,61 +84,170 @@ function AssetCard({ asset, onCopy, isCopied, + onDelete, + onRename, }: { projectId: string; asset: string; onCopy: (path: string) => void; isCopied: boolean; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; }) { const [hovered, setHovered] = useState(false); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [renaming, setRenaming] = useState(false); + const [renameName, setRenameName] = useState(""); const name = asset.split("/").pop() ?? asset; const serveUrl = `/api/projects/${projectId}/preview/${asset}`; const isVideo = VIDEO_EXT.test(asset); return ( -
onCopy(asset)} - onPointerEnter={() => setHovered(true)} - onPointerLeave={() => setHovered(false)} - className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ - isCopied - ? "bg-studio-accent/10 border-l-2 border-studio-accent" - : "border-l-2 border-transparent hover:bg-neutral-800/50" - }`} - > -
- - {/* Inline video autoplay on hover — same pattern as renders */} - {isVideo && hovered && ( -
+ + {/* Context menu */} + {contextMenu && ( +
setContextMenu(null)} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu(null); + }} + > +
+ + {onRename && ( + + )} + {onDelete && ( + + )} +
+
+ )} + ); } -export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) { +export const AssetsTab = memo(function AssetsTab({ + projectId, + assets, + onImport, + onDelete, + onRename, +}: AssetsTabProps) { const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); const [copiedPath, setCopiedPath] = useState(null); @@ -239,6 +350,8 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport } asset={asset} onCopy={handleCopyPath} isCopied={copiedPath === asset} + onDelete={onDelete} + onRename={onRename} /> )) )} diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 7f30c27f5..72f08d170 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -134,7 +134,13 @@ export const LeftSidebar = memo(function LeftSidebar({ /> )} {tab === "assets" && ( - + )} {tab === "code" && (
From d8297e1fe3d61e898a1e8c42d8d9080b0a297f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 19:04:23 +0200 Subject: [PATCH 9/9] fix(studio): address code review findings for file management - [Critical] updateReferences: remove bare-filename replacement that could corrupt unrelated files (only replace full paths now) - [Security] Add null-byte checks on PATCH newPath and duplicate path - [Types] Add onCommit/onCancel to InlineInputState, remove 6 casts - [UX] Add delete confirmation dialog in Assets tab - [UX] Add error logging on failed file operations - [Perf] Memoize sortChildren in TreeFolder Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 23 ++++---------- packages/studio/src/App.tsx | 19 +++++++++++- .../studio/src/components/editor/FileTree.tsx | 26 ++++++---------- .../src/components/sidebar/AssetsTab.tsx | 31 ++++++++++++++++++- 4 files changed, 64 insertions(+), 35 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index b630405f0..1a0b79156 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -111,22 +111,11 @@ function updateReferences(projectDir: string, oldPath: string, newPath: string): for (const file of textFiles) { const content = readFileSync(file, "utf-8"); - // Replace both the full relative path and just the filename - // e.g. "assets/music.mp3" → "assets/background.mp3" - // and "music.mp3" → "background.mp3" (for same-directory refs) - const oldName = oldPath.split("/").pop() ?? oldPath; - const newName = newPath.split("/").pop() ?? newPath; - - let updated = content; - if (content.includes(oldPath)) { - updated = updated.split(oldPath).join(newPath); - } - // Only do filename-level replacement if names actually changed - // and the filename isn't too generic (avoid replacing "a.js" inside "data.js") - if (oldName !== newName && oldName.length > 2 && content.includes(oldName)) { - updated = updated.split(oldName).join(newName); - } + // 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++; @@ -201,7 +190,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if ("error" in res) return res.error; const body = (await c.req.json()) as { newPath?: string }; - if (!body.newPath) { + if (!body.newPath || body.newPath.includes("\0")) { return c.json({ error: "newPath required" }, 400); } @@ -229,7 +218,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (!project) return c.json({ error: "not found" }, 404); const body = (await c.req.json()) as { path: string }; - if (!body.path) { + if (!body.path || body.path.includes("\0")) { return c.json({ error: "path required" }, 400); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index dc858b135..dc79aada0 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -272,6 +272,9 @@ export function StudioApp() { if (res.ok) { await refreshFileTree(); handleFileSelect(path); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Create file failed: ${err.error}`); } }, [refreshFileTree, handleFileSelect], @@ -290,7 +293,12 @@ export function StudioApp() { body: "", }, ); - if (res.ok) await refreshFileTree(); + if (res.ok) { + await refreshFileTree(); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Create folder failed: ${err.error}`); + } }, [refreshFileTree], ); @@ -305,6 +313,9 @@ export function StudioApp() { if (res.ok) { if (editingPathRef.current === path) setEditingFile(null); await refreshFileTree(); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Delete failed: ${err.error}`); } }, [refreshFileTree], @@ -326,6 +337,9 @@ export function StudioApp() { await refreshFileTree(); // Refresh preview — references in compositions may have been updated setRefreshKey((k) => k + 1); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Rename failed: ${err.error}`); } }, [refreshFileTree, handleFileSelect], @@ -344,6 +358,9 @@ export function StudioApp() { const data = await res.json(); await refreshFileTree(); if (data.path) handleFileSelect(data.path); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Duplicate failed: ${err.error}`); } }, [refreshFileTree, handleFileSelect], diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index ce7cb5cc4..4d511463d 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -64,6 +64,8 @@ interface InlineInputState { originalPath?: string; /** For rename mode, the original name */ originalName?: string; + onCommit?: (name: string) => void; + onCancel?: () => void; } // ── Constants ── @@ -449,7 +451,7 @@ function TreeFolder({ }) { const [isOpen, setIsOpen] = useState(defaultOpen); const toggle = useCallback(() => setIsOpen((v) => !v), []); - const children = sortChildren(node.children); + const children = useMemo(() => sortChildren(node.children), [node.children]); const Chevron = isOpen ? ChevronDown : ChevronRight; const isDragOver = dragOverFolder === node.fullPath; const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath; @@ -461,12 +463,10 @@ function TreeFolder({ depth={depth} isFolder={true} onCommit={(name) => { - (inlineInput as InlineInputState & { onCommit?: (name: string) => void }).onCommit?.( - name, - ); + inlineInput?.onCommit?.(name); }} onCancel={() => { - (inlineInput as InlineInputState & { onCancel?: () => void }).onCancel?.(); + inlineInput?.onCancel?.(); }} /> ); @@ -512,12 +512,10 @@ function TreeFolder({ onCommit={(name) => { // onCommit is handled by the parent FileTree component // via the inlineInputCommit callback - ( - inlineInput as InlineInputState & { onCommit?: (name: string) => void } - ).onCommit?.(name); + inlineInput?.onCommit?.(name); }} onCancel={() => { - (inlineInput as InlineInputState & { onCancel?: () => void }).onCancel?.(); + inlineInput?.onCancel?.(); }} /> )} @@ -597,12 +595,10 @@ function TreeFile({ depth={depth} isFolder={false} onCommit={(name) => { - (inlineInput as InlineInputState & { onCommit?: (name: string) => void }).onCommit?.( - name, - ); + inlineInput?.onCommit?.(name); }} onCancel={() => { - (inlineInput as InlineInputState & { onCancel?: () => void }).onCancel?.(); + inlineInput?.onCancel?.(); }} /> ); @@ -647,9 +643,7 @@ export const FileTree = memo(function FileTree({ const children = useMemo(() => sortChildren(tree.children), [tree]); const [contextMenu, setContextMenu] = useState(null); - const [inlineInput, setInlineInput] = useState< - (InlineInputState & { onCommit?: (name: string) => void; onCancel?: () => void }) | null - >(null); + const [inlineInput, setInlineInput] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); const dragSourceRef = useRef(null); diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index 2e7ffaf35..6b8fc16f3 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -98,6 +98,7 @@ function AssetCard({ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [renaming, setRenaming] = useState(false); const [renameName, setRenameName] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(false); const name = asset.split("/").pop() ?? asset; const serveUrl = `/api/projects/${projectId}/preview/${asset}`; const isVideo = VIDEO_EXT.test(asset); @@ -226,7 +227,7 @@ function AssetCard({
)} + + {/* Delete confirmation */} + {confirmDelete && ( +
+ Delete {name}? +
+ + +
+
+ )} ); }