diff --git a/components/Sandboxes/SandboxFileTree.tsx b/components/Sandboxes/SandboxFileTree.tsx index cd9d246d3..53af97e16 100644 --- a/components/Sandboxes/SandboxFileTree.tsx +++ b/components/Sandboxes/SandboxFileTree.tsx @@ -1,15 +1,75 @@ "use client"; +import { useState, useCallback } from "react"; import { FileTree } from "@/components/ai-elements/file-tree"; import FileNodeComponent from "./FileNodeComponent"; import SandboxFilePreview from "./SandboxFilePreview"; import useSandboxes from "@/hooks/useSandboxes"; import useSandboxFileContent from "@/hooks/useSandboxFileContent"; -import { Loader } from "lucide-react"; +import useUploadSandboxFiles from "@/hooks/useUploadSandboxFiles"; +import { useDragAndDrop } from "@/hooks/useDragAndDrop"; +import { toast } from "sonner"; +import { Loader, Upload } from "lucide-react"; +import type { FileNode } from "@/lib/sandboxes/parseFileTree"; + +function getFirstFolderPath(nodes: FileNode[]): string { + for (const node of nodes) { + if (node.type === "folder") return node.path; + } + return ""; +} + +function getParentFolder(filePath: string): string { + const parts = filePath.split("/"); + parts.pop(); + return parts.join("/"); +} export default function SandboxFileTree() { const { filetree, isLoading, error, refetch } = useSandboxes(); const fileContent = useSandboxFileContent(); + const { upload, isUploading } = useUploadSandboxFiles(); + const [dropFolder, setDropFolder] = useState(""); + + const resolvedDropFolder = + dropFolder || + (fileContent.selectedPath ? getParentFolder(fileContent.selectedPath) : "") || + getFirstFolderPath(filetree); + + const handleDrop = useCallback( + async (files: File[]) => { + if (!resolvedDropFolder) { + toast.error("No folder selected. Select a file or folder first."); + return; + } + + toast.loading(`Uploading ${files.length} file${files.length > 1 ? "s" : ""}…`, { + id: "sandbox-upload", + }); + + const results = await upload(files, resolvedDropFolder); + + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success); + + toast.dismiss("sandbox-upload"); + + if (succeeded > 0) { + toast.success(`${succeeded} file${succeeded > 1 ? "s" : ""} uploaded successfully`); + refetch(); + } + + for (const f of failed) { + toast.error(`Failed to upload ${f.path}: ${f.error ?? "unknown error"}`); + } + }, + [resolvedDropFolder, upload, refetch], + ); + + const { getRootProps, getInputProps, isDragging } = useDragAndDrop({ + onDrop: handleDrop, + disabled: isUploading, + }); if (isLoading) { return ( @@ -24,10 +84,7 @@ export default function SandboxFileTree() { return (

Failed to load files

-
@@ -36,7 +93,8 @@ export default function SandboxFileTree() { if (filetree.length === 0) { return ( -
+
+

No files yet.

); @@ -45,12 +103,70 @@ export default function SandboxFileTree() { return (
-

Repository Files

- - {filetree.map((node) => ( - - ))} - +
+ + + {isDragging && ( +
+ +

+ Drop files into{" "} + + {resolvedDropFolder || "repository root"} + +

+
+ )} + +
+
+

Repository Files

+ {isUploading && ( + + + Uploading… + + )} +
+ + {resolvedDropFolder && ( +
+ +

+ Drop target:{" "} + +

+
+ )} + + { + fileContent.select(path); + // Update drop folder to parent of selected file + const parent = getParentFolder(path); + if (parent) setDropFolder(parent); + }} + > + {filetree.map((node) => ( + + ))} + +
+
{fileContent.selectedPath && ( Promise; + isUploading: boolean; + error: string | null; +} + +export default function useUploadSandboxFiles(): UseUploadSandboxFilesReturn { + const { getAccessToken } = usePrivy(); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + + const upload = async (files: File[], folder: string): Promise => { + setIsUploading(true); + setError(null); + try { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to upload files"); + } + const response = await uploadFilesToSandbox(accessToken, files, folder); + if (response.status === "error") { + throw new Error(response.error ?? "Upload failed"); + } + return response.uploaded ?? []; + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + setError(message); + return []; + } finally { + setIsUploading(false); + } + }; + + return { upload, isUploading, error }; +} diff --git a/lib/sandboxes/uploadFilesToSandbox.ts b/lib/sandboxes/uploadFilesToSandbox.ts new file mode 100644 index 000000000..c6e593124 --- /dev/null +++ b/lib/sandboxes/uploadFilesToSandbox.ts @@ -0,0 +1,44 @@ +import { NEW_API_BASE_URL } from "@/lib/consts"; + +export interface UploadFileResult { + path: string; + success: boolean; + error?: string; +} + +export interface UploadFilesResponse { + status: "success" | "error"; + uploaded?: UploadFileResult[]; + error?: string; +} + +/** + * Uploads files to the account's GitHub org submodule via the sandbox files API. + * + * @param accessToken - Privy access token for authentication + * @param files - Array of File objects to upload + * @param folder - Target folder path within the repo (e.g. ".openclaw/workspace/orgs/myorg") + * @returns Upload results per file + */ +export async function uploadFilesToSandbox( + accessToken: string, + files: File[], + folder: string, +): Promise { + const formData = new FormData(); + for (const file of files) { + formData.append("files", file); + } + formData.append("folder", folder); + + const response = await fetch(`${NEW_API_BASE_URL}/api/sandboxes/files`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + }); + + const data: UploadFilesResponse = await response.json(); + return data; +}