From 22312df1a64907bc86fed31b696fdccbd3917cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 18:14:59 +0200 Subject: [PATCH 1/6] feat(studio): drag-drop asset import anywhere in the studio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /api/projects/:id/upload endpoint for multipart file uploads with automatic dedup naming. Wire onImportFiles callback from Assets tab through LeftSidebar to App. Add global drag-drop overlay so media files can be dropped anywhere in the studio — not just the Assets panel. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 39 +++++++++++ packages/studio/src/App.tsx | 71 +++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 1a0b79156..cf170dbc2 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -238,4 +238,43 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { return c.json({ ok: true, path: copyPath }, 201); }); + + // ── Upload (binary assets via multipart form) ── + + api.post("/projects/:id/upload", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + + const formData = await c.req.formData(); + const uploaded: string[] = []; + + for (const [, value] of formData.entries()) { + if (!(value instanceof File)) continue; + + const name = value.name; + if (!name || name.includes("\0") || name.includes("..")) continue; + + const destPath = resolve(project.dir, name); + if (!isSafePath(project.dir, destPath)) continue; + + // Don't overwrite — append (2), (3), etc. + let finalPath = destPath; + let finalName = name; + if (existsSync(finalPath)) { + const ext = name.includes(".") ? "." + name.split(".").pop() : ""; + const base = ext ? name.slice(0, -ext.length) : name; + let n = 2; + while (existsSync(resolve(project.dir, `${base} (${n})${ext}`))) n++; + finalName = `${base} (${n})${ext}`; + finalPath = resolve(project.dir, finalName); + } + + ensureDir(finalPath); + const buffer = Buffer.from(await value.arrayBuffer()); + writeFileSync(finalPath, buffer); + uploaded.push(finalName); + } + + return c.json({ ok: true, files: uploaded }, 201); + }); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index dc79aada0..223c64ae9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -56,6 +56,7 @@ export function StudioApp() { const [rightWidth, setRightWidth] = useState(400); const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(true); + const [globalDragOver, setGlobalDragOver] = useState(false); const [timelineVisible, setTimelineVisible] = useState(false); const panelDragRef = useRef<{ side: "left" | "right"; @@ -368,6 +369,32 @@ export function StudioApp() { const handleMoveFile = handleRenameFile; + const handleImportFiles = useCallback( + async (files: FileList) => { + const pid = projectIdRef.current; + if (!pid || files.length === 0) return; + + const formData = new FormData(); + for (const file of Array.from(files)) { + formData.append("file", file); + } + + try { + const res = await fetch(`/api/projects/${pid}/upload`, { + method: "POST", + body: formData, + }); + if (res.ok) { + await refreshFileTree(); + setRefreshKey((k) => k + 1); + } + } catch { + // ignore + } + }, + [refreshFileTree], + ); + const handleLint = useCallback(async () => { const pid = projectIdRef.current; if (!pid) return; @@ -447,7 +474,22 @@ export function StudioApp() { // At this point projectId is guaranteed non-null (narrowed by the guard above) return ( -
+
{ + e.preventDefault(); + setGlobalDragOver(true); + }} + onDragLeave={(e) => { + // Only reset when leaving the root container + if (e.currentTarget === e.target) setGlobalDragOver(false); + }} + onDrop={(e) => { + e.preventDefault(); + setGlobalDragOver(false); + if (e.dataTransfer.files.length) handleImportFiles(e.dataTransfer.files); + }} + > {/* Header bar */}
{/* Left: project name */} @@ -561,6 +603,7 @@ export function StudioApp() { onRenameFile={handleRenameFile} onDuplicateFile={handleDuplicateFile} onMoveFile={handleMoveFile} + onImportFiles={handleImportFiles} codeChildren={ editingFile ? ( isMediaFile(editingFile.path) ? ( @@ -642,6 +685,32 @@ export function StudioApp() { {lintModal !== null && projectId && ( setLintModal(null)} /> )} + + {/* Global drag-drop overlay */} + {globalDragOver && ( +
+
+ + + + + + + Drop files to import into project + +
+
+ )}
); } From bde87ad364c01eb1ec7149c1fdbc8b1ddc4edb06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 18:42:49 +0200 Subject: [PATCH 2/6] fix(studio): prevent duplicate upload on drag-drop The AssetsTab drop handler and the global drop handler both fired, causing two uploads. Now the global handler checks e.defaultPrevented to skip if a child already handled the drop. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/studio/src/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 223c64ae9..cf00db2ab 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -485,8 +485,10 @@ export function StudioApp() { if (e.currentTarget === e.target) setGlobalDragOver(false); }} onDrop={(e) => { - e.preventDefault(); setGlobalDragOver(false); + // Skip if a child (e.g. AssetsTab) already handled the drop + if (e.defaultPrevented) return; + e.preventDefault(); if (e.dataTransfer.files.length) handleImportFiles(e.dataTransfer.files); }} > From cf6a99def769e6ecb72c67133fb032574d589f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 18:57:01 +0200 Subject: [PATCH 3/6] feat(studio): support drag-drop file import in Code tab file tree External files dropped onto the file tree or any folder are imported via the upload API, same as the Assets tab and global drop zone. Internal drags (file-to-folder moves) still work as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../studio/src/components/editor/FileTree.tsx | 29 +++++++++++++++++-- .../src/components/sidebar/LeftSidebar.tsx | 1 + 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 4d511463d..e96d98f89 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -39,6 +39,7 @@ export interface FileTreeProps { onRenameFile?: (oldPath: string, newPath: string) => void; onDuplicateFile?: (path: string) => void; onMoveFile?: (oldPath: string, newPath: string) => void; + onImportFiles?: (files: FileList) => void; } interface TreeNode { @@ -638,6 +639,7 @@ export const FileTree = memo(function FileTree({ onRenameFile, onDuplicateFile, onMoveFile, + onImportFiles, }: FileTreeProps) { const tree = useMemo(() => buildTree(files), [files]); const children = useMemo(() => sortChildren(tree.children), [tree]); @@ -770,7 +772,15 @@ export const FileTree = memo(function FileTree({ }, []); const handleDrop = useCallback( - (_e: React.DragEvent, folderPath: string) => { + (e: React.DragEvent, folderPath: string) => { + // External files from desktop — import them + if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) { + e.preventDefault(); + onImportFiles?.(e.dataTransfer.files); + setDragOverFolder(null); + return; + } + const sourcePath = dragSourceRef.current; if (!sourcePath || !onMoveFile) { setDragOverFolder(null); @@ -788,7 +798,7 @@ export const FileTree = memo(function FileTree({ setDragOverFolder(null); dragSourceRef.current = null; }, - [onMoveFile], + [onMoveFile, onImportFiles], ); const handleDragLeave = useCallback(() => { @@ -836,7 +846,20 @@ export const FileTree = memo(function FileTree({
)} -
+
{ + // Allow external file drops on the root area + if (e.dataTransfer.types.includes("Files")) e.preventDefault(); + }} + onDrop={(e) => { + if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) { + e.preventDefault(); + onImportFiles?.(e.dataTransfer.files); + } + }} + > {/* Root-level inline input for new file/folder */} {inlineInput && (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 72f08d170..403861cc9 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -156,6 +156,7 @@ export const LeftSidebar = memo(function LeftSidebar({ onRenameFile={onRenameFile} onDuplicateFile={onDuplicateFile} onMoveFile={onMoveFile} + onImportFiles={onImportFiles} />
)} From 0c0adba2e2a77bedc0fc429421f1280df42c564e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 19:05:25 +0200 Subject: [PATCH 4/6] fix(studio): address code review findings for asset import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [Security] Add 500MB per-file size limit on uploads to prevent OOM - [Security] Strip path separators from uploaded filenames - [Bug] Fix dotfile collision naming (.gitignore → correct extension split) - [Bug] Only show global drop overlay for external file drags, not tree reorders - [UX] Log upload errors and report skipped files to console - [Safety] Cap collision counter at 10000 iterations Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 22 +++++++++++++++----- packages/studio/src/App.tsx | 12 +++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index cf170dbc2..5e74643e6 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -241,19 +241,29 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { // ── Upload (binary assets via multipart form) ── + const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file + api.post("/projects/:id/upload", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); const formData = await c.req.formData(); const uploaded: string[] = []; + const skipped: string[] = []; for (const [, value] of formData.entries()) { if (!(value instanceof File)) continue; - const name = value.name; + // Strip path separators — browsers may include directory components + const name = value.name.split("/").pop()?.split("\\").pop() ?? ""; if (!name || name.includes("\0") || name.includes("..")) continue; + // Reject files that exceed the size limit + if (value.size > MAX_UPLOAD_BYTES) { + skipped.push(name); + continue; + } + const destPath = resolve(project.dir, name); if (!isSafePath(project.dir, destPath)) continue; @@ -261,10 +271,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { let finalPath = destPath; let finalName = name; if (existsSync(finalPath)) { - const ext = name.includes(".") ? "." + name.split(".").pop() : ""; - const base = ext ? name.slice(0, -ext.length) : name; + // Handle dotfiles correctly: .gitignore → ext="", base=".gitignore" + const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0); + const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; + const base = dotIdx > 0 ? name.slice(0, dotIdx) : name; let n = 2; - while (existsSync(resolve(project.dir, `${base} (${n})${ext}`))) n++; + while (n < 10000 && existsSync(resolve(project.dir, `${base} (${n})${ext}`))) n++; finalName = `${base} (${n})${ext}`; finalPath = resolve(project.dir, finalName); } @@ -275,6 +287,6 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { uploaded.push(finalName); } - return c.json({ ok: true, files: uploaded }, 201); + return c.json({ ok: true, files: uploaded, skipped }, 201); }); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index cf00db2ab..d327d32d1 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -385,11 +385,17 @@ export function StudioApp() { body: formData, }); if (res.ok) { + const data = await res.json(); + if (data.skipped?.length) { + console.warn(`Skipped files (too large): ${data.skipped.join(", ")}`); + } await refreshFileTree(); setRefreshKey((k) => k + 1); + } else { + console.error(`Upload failed: ${res.status}`); } - } catch { - // ignore + } catch (err) { + console.error("Upload error:", err); } }, [refreshFileTree], @@ -477,6 +483,8 @@ export function StudioApp() {
{ + // Only show overlay for external file drags (not internal tree reorders) + if (!e.dataTransfer.types.includes("Files")) return; e.preventDefault(); setGlobalDragOver(true); }} From 480ed28f02a519e6e3bb4918aded9d4873692a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 23:36:07 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix(studio):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20upload=20safety,=20drag=20overlay,=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant ensureDir call, add bodyLimit middleware for early rejection - Skip file on dedup suffix exhaustion instead of silent fallthrough - Replace fragile currentTarget===target drag guard with counter ref pattern - Surface skipped/failed uploads via toast instead of console-only - Support ?dir= param for uploading into subdirectories Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/files.ts | 105 ++++++++++-------- packages/studio/src/App.tsx | 42 +++++-- .../studio/src/components/editor/FileTree.tsx | 6 +- .../src/components/sidebar/LeftSidebar.tsx | 2 +- 4 files changed, 96 insertions(+), 59 deletions(-) diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 5e74643e6..0abfec0d0 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 { bodyLimit } from "hono/body-limit"; import { existsSync, readFileSync, @@ -243,50 +244,66 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file - api.post("/projects/:id/upload", async (c) => { - const project = await adapter.resolveProject(c.req.param("id")); - if (!project) return c.json({ error: "not found" }, 404); - - const formData = await c.req.formData(); - const uploaded: string[] = []; - const skipped: string[] = []; - - for (const [, value] of formData.entries()) { - if (!(value instanceof File)) continue; - - // Strip path separators — browsers may include directory components - const name = value.name.split("/").pop()?.split("\\").pop() ?? ""; - if (!name || name.includes("\0") || name.includes("..")) continue; - - // Reject files that exceed the size limit - if (value.size > MAX_UPLOAD_BYTES) { - skipped.push(name); - continue; + api.post( + "/projects/:id/upload", + bodyLimit({ + maxSize: MAX_UPLOAD_BYTES, + onError: (c) => c.json({ error: "payload too large" }, 413), + }), + async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + + // Optional subdirectory within the project (e.g. "assets/audio") + const subDir = c.req.query("dir") ?? ""; + const targetDir = subDir ? resolve(project.dir, subDir) : project.dir; + if (!isSafePath(project.dir, targetDir)) return c.json({ error: "forbidden" }, 403); + if (subDir && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); + + const formData = await c.req.formData(); + const uploaded: string[] = []; + const skipped: string[] = []; + + for (const [, value] of formData.entries()) { + if (!(value instanceof File)) continue; + + // Strip path separators — browsers may include directory components + const name = value.name.split("/").pop()?.split("\\").pop() ?? ""; + if (!name || name.includes("\0") || name.includes("..")) continue; + + // Reject individual files that exceed the size limit + if (value.size > MAX_UPLOAD_BYTES) { + skipped.push(name); + continue; + } + + const destPath = resolve(targetDir, name); + if (!isSafePath(project.dir, destPath)) continue; + + // Don't overwrite — append (2), (3), etc. + let finalPath = destPath; + let finalName = name; + if (existsSync(finalPath)) { + // Handle dotfiles correctly: .gitignore → ext="", base=".gitignore" + const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0); + const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; + const base = dotIdx > 0 ? name.slice(0, dotIdx) : name; + let n = 2; + while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++; + if (n >= 10000) { + skipped.push(name); + continue; + } + finalName = `${base} (${n})${ext}`; + finalPath = resolve(targetDir, finalName); + } + + const buffer = Buffer.from(await value.arrayBuffer()); + writeFileSync(finalPath, buffer); + uploaded.push(subDir ? join(subDir, finalName) : finalName); } - const destPath = resolve(project.dir, name); - if (!isSafePath(project.dir, destPath)) continue; - - // Don't overwrite — append (2), (3), etc. - let finalPath = destPath; - let finalName = name; - if (existsSync(finalPath)) { - // Handle dotfiles correctly: .gitignore → ext="", base=".gitignore" - const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0); - const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; - const base = dotIdx > 0 ? name.slice(0, dotIdx) : name; - let n = 2; - while (n < 10000 && existsSync(resolve(project.dir, `${base} (${n})${ext}`))) n++; - finalName = `${base} (${n})${ext}`; - finalPath = resolve(project.dir, finalName); - } - - ensureDir(finalPath); - const buffer = Buffer.from(await value.arrayBuffer()); - writeFileSync(finalPath, buffer); - uploaded.push(finalName); - } - - return c.json({ ok: true, files: uploaded, skipped }, 201); - }); + return c.json({ ok: true, files: uploaded, skipped }, 201); + }, + ); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index d327d32d1..6823b6c13 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -57,7 +57,9 @@ export function StudioApp() { const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(true); const [globalDragOver, setGlobalDragOver] = useState(false); + const [uploadToast, setUploadToast] = useState(null); const [timelineVisible, setTimelineVisible] = useState(false); + const dragCounterRef = useRef(0); const panelDragRef = useRef<{ side: "left" | "right"; startX: number; @@ -369,8 +371,13 @@ export function StudioApp() { const handleMoveFile = handleRenameFile; + const showUploadToast = useCallback((msg: string) => { + setUploadToast(msg); + setTimeout(() => setUploadToast(null), 4000); + }, []); + const handleImportFiles = useCallback( - async (files: FileList) => { + async (files: FileList, dir?: string) => { const pid = projectIdRef.current; if (!pid || files.length === 0) return; @@ -379,26 +386,29 @@ export function StudioApp() { formData.append("file", file); } + const qs = dir ? `?dir=${encodeURIComponent(dir)}` : ""; try { - const res = await fetch(`/api/projects/${pid}/upload`, { + const res = await fetch(`/api/projects/${pid}/upload${qs}`, { method: "POST", body: formData, }); if (res.ok) { const data = await res.json(); if (data.skipped?.length) { - console.warn(`Skipped files (too large): ${data.skipped.join(", ")}`); + showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`); } await refreshFileTree(); setRefreshKey((k) => k + 1); + } else if (res.status === 413) { + showUploadToast("Upload rejected: payload too large"); } else { - console.error(`Upload failed: ${res.status}`); + showUploadToast(`Upload failed (${res.status})`); } - } catch (err) { - console.error("Upload error:", err); + } catch { + showUploadToast("Upload failed: network error"); } }, - [refreshFileTree], + [refreshFileTree, showUploadToast], ); const handleLint = useCallback(async () => { @@ -483,16 +493,21 @@ export function StudioApp() {
{ - // Only show overlay for external file drags (not internal tree reorders) if (!e.dataTransfer.types.includes("Files")) return; e.preventDefault(); + }} + onDragEnter={(e) => { + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + dragCounterRef.current++; setGlobalDragOver(true); }} - onDragLeave={(e) => { - // Only reset when leaving the root container - if (e.currentTarget === e.target) setGlobalDragOver(false); + onDragLeave={() => { + dragCounterRef.current--; + if (dragCounterRef.current === 0) setGlobalDragOver(false); }} onDrop={(e) => { + dragCounterRef.current = 0; setGlobalDragOver(false); // Skip if a child (e.g. AssetsTab) already handled the drop if (e.defaultPrevented) return; @@ -721,6 +736,11 @@ export function StudioApp() {
)} + {uploadToast && ( +
+ {uploadToast} +
+ )}
); } diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index e96d98f89..2fd485093 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -39,7 +39,7 @@ export interface FileTreeProps { onRenameFile?: (oldPath: string, newPath: string) => void; onDuplicateFile?: (path: string) => void; onMoveFile?: (oldPath: string, newPath: string) => void; - onImportFiles?: (files: FileList) => void; + onImportFiles?: (files: FileList, dir?: string) => void; } interface TreeNode { @@ -773,10 +773,10 @@ export const FileTree = memo(function FileTree({ const handleDrop = useCallback( (e: React.DragEvent, folderPath: string) => { - // External files from desktop — import them + // External files from desktop — import into the target folder if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) { e.preventDefault(); - onImportFiles?.(e.dataTransfer.files); + onImportFiles?.(e.dataTransfer.files, folderPath || undefined); setDragOverFolder(null); return; } diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 403861cc9..5c211d0a8 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -22,7 +22,7 @@ interface LeftSidebarProps { assets: string[]; activeComposition: string | null; onSelectComposition: (comp: string) => void; - onImportFiles?: (files: FileList) => void; + onImportFiles?: (files: FileList, dir?: string) => void; fileTree?: string[]; editingFile?: { path: string; content: string | null } | null; onSelectFile?: (path: string) => void; From ce5ceaaac1b919d26609a6f9cdffbe8616048931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 23:43:35 +0200 Subject: [PATCH 6/6] feat(studio): make folders draggable + support drop-to-root in file tree - Add draggable + onDragStart to TreeFolder so folders can be moved - Root drop zone now handles internal moves (files/folders to root) - Visual drag-over highlight on root area when dragging items over it Co-Authored-By: Claude Opus 4.6 (1M context) --- .../studio/src/components/editor/FileTree.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 2fd485093..05d598290 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -476,6 +476,8 @@ function TreeFolder({ return ( <>