Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import {
existsSync,
readFileSync,
Expand Down Expand Up @@ -238,4 +239,71 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {

return c.json({ ok: true, path: copyPath }, 201);
});

// ── Upload (binary assets via multipart form) ──

const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB per file

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);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: join is used here but I don't see it added to the node:path import in the diff. Can you verify it's imported? TypeScript should catch this at build time, but worth double-checking.

return c.json({ ok: true, files: uploaded, skipped }, 201);
},
);
}
101 changes: 100 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export function StudioApp() {
const [rightWidth, setRightWidth] = useState(400);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(true);
const [globalDragOver, setGlobalDragOver] = useState(false);
const [uploadToast, setUploadToast] = useState<string | null>(null);
const [timelineVisible, setTimelineVisible] = useState(false);
const dragCounterRef = useRef(0);
const panelDragRef = useRef<{
side: "left" | "right";
startX: number;
Expand Down Expand Up @@ -368,6 +371,46 @@ export function StudioApp() {

const handleMoveFile = handleRenameFile;

const showUploadToast = useCallback((msg: string) => {
setUploadToast(msg);
setTimeout(() => setUploadToast(null), 4000);
}, []);
Comment on lines +374 to +377
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor: setTimeout without cleanup — if the component unmounts before the 4s fires, React will warn about setting state on an unmounted component. Not a real issue for the studio shell (it stays mounted), but if you want to be tidy:

const toastTimerRef = useRef<ReturnType<typeof setTimeout>>();

const showUploadToast = useCallback((msg: string) => {
  clearTimeout(toastTimerRef.current);
  setUploadToast(msg);
  toastTimerRef.current = setTimeout(() => setUploadToast(null), 4000);
}, []);

This also gives you the bonus of resetting the timer if a second toast fires before the first clears. Non-blocking.


const handleImportFiles = useCallback(
async (files: FileList, dir?: string) => {
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);
}

const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
try {
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) {
showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`);
}
await refreshFileTree();
setRefreshKey((k) => k + 1);
Comment thread
miguel-heygen marked this conversation as resolved.
} else if (res.status === 413) {
showUploadToast("Upload rejected: payload too large");
} else {
showUploadToast(`Upload failed (${res.status})`);
}
} catch {
showUploadToast("Upload failed: network error");
}
},
[refreshFileTree, showUploadToast],
);

const handleLint = useCallback(async () => {
const pid = projectIdRef.current;
if (!pid) return;
Expand Down Expand Up @@ -447,7 +490,31 @@ export function StudioApp() {
// At this point projectId is guaranteed non-null (narrowed by the guard above)

return (
<div className="flex flex-col h-screen w-screen bg-neutral-950">
<div
className="flex flex-col h-screen w-screen bg-neutral-950 relative"
onDragOver={(e) => {
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={() => {
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;
e.preventDefault();
if (e.dataTransfer.files.length) handleImportFiles(e.dataTransfer.files);
}}
>
{/* Header bar */}
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
{/* Left: project name */}
Expand Down Expand Up @@ -561,6 +628,7 @@ export function StudioApp() {
onRenameFile={handleRenameFile}
onDuplicateFile={handleDuplicateFile}
onMoveFile={handleMoveFile}
onImportFiles={handleImportFiles}
codeChildren={
editingFile ? (
isMediaFile(editingFile.path) ? (
Expand Down Expand Up @@ -642,6 +710,37 @@ export function StudioApp() {
{lintModal !== null && projectId && (
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
)}

{/* Global drag-drop overlay */}
{globalDragOver && (
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-studio-accent"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-sm font-medium text-studio-accent">
Drop files to import into project
</span>
</div>
</div>
)}
{uploadToast && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg bg-red-900/90 border border-red-700/50 text-sm text-red-200 shadow-lg animate-in fade-in slide-in-from-bottom-2">
{uploadToast}
</div>
)}
</div>
);
}
37 changes: 34 additions & 3 deletions packages/studio/src/components/editor/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, dir?: string) => void;
}

interface TreeNode {
Expand Down Expand Up @@ -475,6 +476,8 @@ function TreeFolder({
return (
<>
<button
draggable
onDragStart={(e) => onDragStart(e, node.fullPath)}
onClick={toggle}
onContextMenu={(e) => {
e.preventDefault();
Expand Down Expand Up @@ -638,6 +641,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]);
Expand Down Expand Up @@ -770,7 +774,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 into the target folder
if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) {
e.preventDefault();
onImportFiles?.(e.dataTransfer.files, folderPath || undefined);
setDragOverFolder(null);
return;
}

const sourcePath = dragSourceRef.current;
if (!sourcePath || !onMoveFile) {
setDragOverFolder(null);
Expand All @@ -788,7 +800,7 @@ export const FileTree = memo(function FileTree({
setDragOverFolder(null);
dragSourceRef.current = null;
},
[onMoveFile],
[onMoveFile, onImportFiles],
);

const handleDragLeave = useCallback(() => {
Expand Down Expand Up @@ -836,7 +848,26 @@ export const FileTree = memo(function FileTree({
</div>
)}

<div className="flex-1 overflow-y-auto py-1" onContextMenu={handleRootContextMenu}>
<div
className={`flex-1 overflow-y-auto py-1 transition-colors ${
dragOverFolder === ""
? "bg-[#3CE6AC]/5 outline outline-1 outline-[#3CE6AC]/30 -outline-offset-1"
: ""
}`}
onContextMenu={handleRootContextMenu}
onDragOver={(e) => {
e.preventDefault();
// Show root highlight when dragging over the background (not a child folder)
if (e.target === e.currentTarget) setDragOverFolder("");
}}
onDragLeave={(e) => {
if (e.target === e.currentTarget) setDragOverFolder(null);
}}
onDrop={(e) => {
e.preventDefault();
handleDrop(e, "");
}}
>
{/* Root-level inline input for new file/folder */}
{inlineInput &&
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/components/sidebar/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -156,6 +156,7 @@ export const LeftSidebar = memo(function LeftSidebar({
onRenameFile={onRenameFile}
onDuplicateFile={onDuplicateFile}
onMoveFile={onMoveFile}
onImportFiles={onImportFiles}
/>
</div>
)}
Expand Down
Loading