diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 8931790b..1a0b7915 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -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 res = await resolveProjectFile(c, adapter); + if ("error" in res) return res.error; + + ensureDir(res.absPath); + const body = await c.req.text(); + 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 filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, "")); - const file = resolve(project.dir, filePath); - if (!isSafePath(project.dir, file)) { + + 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); } - const dir = dirname(file); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const body = await c.req.text(); - writeFileSync(file, body, "utf-8"); - return c.json({ ok: true }); + + ensureDir(destAbs); + writeFileSync(destAbs, readFileSync(srcAbs)); + + return c.json({ ok: true, path: copyPath }, 201); }); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4728395f..dc79aada 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); @@ -246,6 +245,129 @@ 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); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Create file failed: ${err.error}`); + } + }, + [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(); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Create folder failed: ${err.error}`); + } + }, + [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 (editingPathRef.current === path) setEditingFile(null); + await refreshFileTree(); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Delete failed: ${err.error}`); + } + }, + [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 (editingPathRef.current === oldPath) { + handleFileSelect(newPath); + } + 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], + ); + + 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); + } else { + const err = await res.json().catch(() => ({ error: "unknown" })); + console.error(`Duplicate failed: ${err.error}`); + } + }, + [refreshFileTree, handleFileSelect], + ); + + const handleMoveFile = handleRenameFile; + const handleLint = useCallback(async () => { const pid = projectIdRef.current; if (!pid) return; @@ -433,6 +555,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 9bc2c40d..4d511463 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,64 @@ 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; + onCommit?: (name: string) => void; + onCancel?: () => void; +} + +// ── 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 +105,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 +143,476 @@ 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 committedRef = useRef(false); + 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 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 && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed); + else onCancel(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + const handleBlur = () => { + const trimmed = value.trim(); + if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) + commit(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 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; + + if (isRenaming) { + return ( + { + inlineInput?.onCommit?.(name); + }} + onCancel={() => { + inlineInput?.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?.onCommit?.(name); + }} + onCancel={() => { + inlineInput?.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?.onCommit?.(name); + }} + onCancel={() => { + inlineInput?.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/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index 9207a9a3..6b8fc16f 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,199 @@ 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 [confirmDelete, setConfirmDelete] = useState(false); 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 && ( + + )} +
+
+ )} + + {/* Delete confirmation */} + {confirmDelete && ( +
+ Delete {name}? +
+ + +
+
+ )} + ); } -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 +379,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 a4665d18..72f08d17 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, @@ -122,16 +134,28 @@ export const LeftSidebar = memo(function LeftSidebar({ /> )} {tab === "assets" && ( - + )} {tab === "code" && (
{(fileProp?.length ?? 0) > 0 && ( -
+
{})} + onCreateFile={onCreateFile} + onCreateFolder={onCreateFolder} + onDeleteFile={onDeleteFile} + onRenameFile={onRenameFile} + onDuplicateFile={onDuplicateFile} + onMoveFile={onMoveFile} />
)}