Skip to content

Commit

Permalink
Implement file-by-file download with progress
Browse files Browse the repository at this point in the history
- Add file-by-file download functionality similar to VS Code
- Show download progress with file names and transfer speed
- Support recursive directory traversal
- Create client-side zip file preserving directory structure
  • Loading branch information
openhands-agent committed Nov 14, 2024
1 parent be92965 commit 4c8c100
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 0 deletions.
89 changes: 89 additions & 0 deletions frontend/src/components/download-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { downloadFiles } from "#/utils/download-files";

interface DownloadProgressProps {
initialPath?: string;
onClose: () => void;
}

export function DownloadProgress({ initialPath, onClose }: DownloadProgressProps) {

Check failure on line 9 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Replace `·initialPath,·onClose·` with `⏎··initialPath,⏎··onClose,⏎`
const [progress, setProgress] = useState({
filesTotal: 0,
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0

Check failure on line 15 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Insert `,`
});

const abortController = useRef(new AbortController());

const handleDownload = useCallback(async () => {
try {
await downloadFiles(initialPath, {
onProgress: setProgress,
signal: abortController.current.signal

Check failure on line 24 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Insert `,`
});
onClose();
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
onClose();
} else {
console.error("Download error:", error);

Check warning on line 31 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Unexpected console statement
}
}
}, [initialPath, onClose]);

useEffect(() => {
handleDownload();
return () => abortController.current.abort();
}, [handleDownload]);

const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;

Check failure on line 47 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Unary operator '++' used
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};

return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2">Downloading Files</h3>
<p className="text-sm text-gray-600 truncate">{progress.currentFile}</p>

Check failure on line 57 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Replace `{progress.currentFile}` with `⏎············{progress.currentFile}⏎··········`
</div>

<div className="mb-4">
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div

Check failure on line 62 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Delete `·`
className="h-full bg-blue-500 transition-all duration-300"
style={{

Check failure on line 64 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Delete `·`
width: `${(progress.filesDownloaded / progress.filesTotal) * 100}%`

Check failure on line 65 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Insert `,`
}}
/>
</div>
</div>

<div className="flex justify-between text-sm text-gray-600">
<span>
{progress.filesDownloaded} of {progress.filesTotal} files
</span>
<span>{formatBytes(progress.bytesDownloadedPerSecond)}/s</span>
</div>

<div className="mt-4 flex justify-end">
<button

Check failure on line 79 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Missing an explicit type attribute for button
onClick={() => abortController.current.abort()}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
</div>
</div>
</div>
);
}

Check failure on line 89 in frontend/src/components/download-progress.tsx

View workflow job for this annotation

GitHub Actions / Lint frontend

Insert `⏎`
31 changes: 31 additions & 0 deletions frontend/src/components/file-explorer/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
IoIosArrowForward,
IoIosRefresh,
IoIosCloudUpload,
IoIosCloudDownload,
} from "react-icons/io";
import { useRevalidator } from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
Expand All @@ -22,10 +23,12 @@ import OpenHands from "#/api/open-hands";
import { useFiles } from "#/context/files";
import { isOpenHandsErrorResponse } from "#/api/open-hands.utils";
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
import { DownloadProgress } from "../download-progress";

interface ExplorerActionsProps {
onRefresh: () => void;
onUpload: () => void;
onDownload: () => void;
toggleHidden: () => void;
isHidden: boolean;
}
Expand All @@ -34,6 +37,7 @@ function ExplorerActions({
toggleHidden,
onRefresh,
onUpload,
onDownload,
isHidden,
}: ExplorerActionsProps) {
return (
Expand Down Expand Up @@ -67,6 +71,17 @@ function ExplorerActions({
ariaLabel="Upload File"
onClick={onUpload}
/>
<IconButton
icon={
<IoIosCloudDownload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="download"
ariaLabel="Download Files"
onClick={onDownload}
/>
</>
)}

Expand Down Expand Up @@ -103,6 +118,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {

const { paths, setPaths } = useFiles();
const [isDragging, setIsDragging] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);

const { curAgentState } = useSelector((state: RootState) => state.agent);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
Expand All @@ -112,6 +128,14 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
fileInputRef.current?.click(); // Trigger the file browser
};

const handleDownload = () => {
setIsDownloading(true);
};

const handleDownloadClose = () => {
setIsDownloading(false);
};

const refreshWorkspace = () => {
if (
curAgentState === AgentState.LOADING ||
Expand Down Expand Up @@ -235,6 +259,12 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
</p>
</div>
)}
{isDownloading && (
<DownloadProgress
initialPath=""
onClose={handleDownloadClose}
/>
)}
<div
className={twMerge(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
Expand All @@ -259,6 +289,7 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
toggleHidden={onToggle}
onRefresh={refreshWorkspace}
onUpload={selectFileInput}
onDownload={handleDownload}
/>
</div>
</div>
Expand Down
113 changes: 113 additions & 0 deletions frontend/src/utils/download-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import OpenHands from "#/api/open-hands";

interface DownloadProgress {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
}

interface DownloadOptions {
onProgress?: (progress: DownloadProgress) => void;
signal?: AbortSignal;
}

/**
* Recursively gets all files in a directory
*/
async function getAllFiles(
path: string,
progress: DownloadProgress,
options?: DownloadOptions
): Promise<string[]> {
const files: string[] = [];
const entries = await OpenHands.getFiles(path);

for (const entry of entries) {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}

if (entry.endsWith("/")) {
// It's a directory, recursively get its files
const subFiles = await getAllFiles(entry, progress, options);
files.push(...subFiles);
} else {
files.push(entry);
progress.filesTotal++;
options?.onProgress?.(progress);
}
}

return files;
}

/**
* Downloads files from the workspace one by one
* @param initialPath Initial path to start downloading from. If not provided, downloads from root
* @param options Download options including progress callback and abort signal
*/
export async function downloadFiles(initialPath?: string, options?: DownloadOptions): Promise<void> {
const startTime = Date.now();
const progress: DownloadProgress = {
filesTotal: 0,
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0
};

try {
// First, recursively get all files
const files = await getAllFiles(initialPath || "", progress, options);

// Create a zip file using JSZip
const JSZip = (await import("jszip")).default;
const zip = new JSZip();

// Download each file
for (const path of files) {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}

try {
progress.currentFile = path;
const content = await OpenHands.getFile(path);

// Add file to zip, preserving directory structure
zip.file(path, content);

// Update progress
progress.filesDownloaded++;
progress.totalBytesDownloaded += new Blob([content]).size;
progress.bytesDownloadedPerSecond = progress.totalBytesDownloaded / ((Date.now() - startTime) / 1000);
options?.onProgress?.(progress);
} catch (error) {
console.error(`Error downloading file ${path}:`, error);

Check warning on line 88 in frontend/src/utils/download-files.ts

View workflow job for this annotation

GitHub Actions / Lint frontend

Unexpected console statement
}
}

if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}

// Generate and download the zip file
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", initialPath ? `${initialPath.replace(/\/$/, "")}.zip` : "workspace.zip");
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
throw error;
}
console.error("Download error:", error);

Check warning on line 110 in frontend/src/utils/download-files.ts

View workflow job for this annotation

GitHub Actions / Lint frontend

Unexpected console statement
throw new Error("Failed to download files");
}
}

0 comments on commit 4c8c100

Please sign in to comment.