Skip to content
Open
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
140 changes: 128 additions & 12 deletions components/Sandboxes/SandboxFileTree.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("");

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;
Comment on lines +41 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow drops when the repository tree is empty

This guard makes the new empty-state drop zone unusable. In the filetree.length === 0 branch we still mount getRootProps() (components/Sandboxes/SandboxFileTree.tsx:94-99), but resolvedDropFolder can only come from dropFolder, selectedPath, or getFirstFolderPath(filetree). When the tree is empty all three are "", so every drop immediately returns here with "No folder selected". That means a brand-new org repo cannot be populated via drag-and-drop at all.

Useful? React with 👍 / 👎.

}

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 (
Expand All @@ -24,10 +84,7 @@ export default function SandboxFileTree() {
return (
<div className="text-destructive">
<p>Failed to load files</p>
<button
onClick={() => refetch()}
className="text-sm underline hover:no-underline"
>
<button onClick={() => refetch()} className="text-sm underline hover:no-underline">
Try again
</button>
</div>
Expand All @@ -36,7 +93,8 @@ export default function SandboxFileTree() {

if (filetree.length === 0) {
return (
<div className="w-full max-w-md">
<div {...getRootProps()} className="w-full max-w-md">
<input {...getInputProps()} />
<p className="text-sm text-muted-foreground">No files yet.</p>
</div>
);
Expand All @@ -45,12 +103,70 @@ export default function SandboxFileTree() {
return (
<div className="flex w-full flex-col gap-4 lg:flex-row">
<div className="w-full lg:max-w-md lg:shrink-0">
<h2 className="mb-2 text-lg font-medium">Repository Files</h2>
<FileTree selectedPath={fileContent.selectedPath} onSelect={fileContent.select}>
{filetree.map((node) => (
<FileNodeComponent key={node.path} node={node} />
))}
</FileTree>
<div
{...getRootProps()}
className={`relative rounded-lg border border-dashed transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-transparent hover:border-muted-foreground/30"
}`}
>
<input {...getInputProps()} />

{isDragging && (
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-background/80">
<Upload className="h-8 w-8 text-primary" />
<p className="text-sm font-medium text-primary">
Drop files into{" "}
<span className="font-mono text-xs">
{resolvedDropFolder || "repository root"}
</span>
</p>
</div>
)}

<div className="p-1">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-lg font-medium">Repository Files</h2>
{isUploading && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader className="h-3 w-3 animate-spin" />
Uploading…
</span>
)}
</div>

{resolvedDropFolder && (
<div className="mb-2 flex items-center gap-1">
<Upload className="h-3 w-3 text-muted-foreground" />
<p className="truncate font-mono text-xs text-muted-foreground">
Drop target:{" "}
<button
className="underline decoration-dotted hover:text-foreground"
onClick={() => setDropFolder("")}
title="Reset to default"
>
{resolvedDropFolder}
</button>
</p>
</div>
)}

<FileTree
selectedPath={fileContent.selectedPath}
onSelect={(path) => {
fileContent.select(path);
// Update drop folder to parent of selected file
const parent = getParentFolder(path);
if (parent) setDropFolder(parent);
Comment on lines +160 to +161
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Target the selected folder instead of its parent

FileTreeFolder forwards its own folder path to onSelect (components/ai-elements/file-tree.tsx:131-133), but this handler always converts the selection to getParentFolder(path). As soon as a user clicks a nested folder like org/assets, the drop target becomes org, so uploads land in the parent directory instead of the folder they picked. It also makes empty folders impossible to target, because there is no file inside them to select first.

Useful? React with 👍 / 👎.

}}
>
{filetree.map((node) => (
<FileNodeComponent key={node.path} node={node} />
))}
</FileTree>
</div>
</div>
</div>
{fileContent.selectedPath && (
<SandboxFilePreview
Expand Down
42 changes: 42 additions & 0 deletions hooks/useUploadSandboxFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { useState } from "react";
import { usePrivy } from "@privy-io/react-auth";
import { uploadFilesToSandbox } from "@/lib/sandboxes/uploadFilesToSandbox";
import type { UploadFileResult } from "@/lib/sandboxes/uploadFilesToSandbox";

interface UseUploadSandboxFilesReturn {
upload: (files: File[], folder: string) => Promise<UploadFileResult[]>;
isUploading: boolean;
error: string | null;
}

export default function useUploadSandboxFiles(): UseUploadSandboxFilesReturn {
const { getAccessToken } = usePrivy();
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);

const upload = async (files: File[], folder: string): Promise<UploadFileResult[]> => {
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 [];
Comment on lines +32 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate request-level upload failures to the UI

SandboxFileTree.handleDrop only renders failure toasts for entries returned in results (components/Sandboxes/SandboxFileTree.tsx:52-63), but this catch block swallows auth/network/5xx failures and returns []. In those cases the loading toast is dismissed and the user sees no error at all, so a broken upload path looks like a no-op. Re-throwing here, or returning a synthetic failed result, would keep those failures visible.

Useful? React with 👍 / 👎.

} finally {
setIsUploading(false);
}
};

return { upload, isUploading, error };
}
44 changes: 44 additions & 0 deletions lib/sandboxes/uploadFilesToSandbox.ts
Original file line number Diff line number Diff line change
@@ -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<UploadFilesResponse> {
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;
}
Loading