diff --git a/frontend/src/components/download-progress.tsx b/frontend/src/components/download-progress.tsx
new file mode 100644
index 000000000000..8c6792e96a5f
--- /dev/null
+++ b/frontend/src/components/download-progress.tsx
@@ -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) {
+ const [progress, setProgress] = useState({
+ filesTotal: 0,
+ filesDownloaded: 0,
+ currentFile: "",
+ totalBytesDownloaded: 0,
+ bytesDownloadedPerSecond: 0
+ });
+
+ const abortController = useRef(new AbortController());
+
+ const handleDownload = useCallback(async () => {
+ try {
+ await downloadFiles(initialPath, {
+ onProgress: setProgress,
+ signal: abortController.current.signal
+ });
+ onClose();
+ } catch (error) {
+ if (error instanceof Error && error.message === "Download cancelled") {
+ onClose();
+ } else {
+ console.error("Download error:", error);
+ }
+ }
+ }, [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++;
+ }
+ return `${size.toFixed(1)} ${units[unitIndex]}`;
+ };
+
+ return (
+
+
+
+
Downloading Files
+
{progress.currentFile}
+
+
+
+
+
+
+ {progress.filesDownloaded} of {progress.filesTotal} files
+
+ {formatBytes(progress.bytesDownloadedPerSecond)}/s
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/file-explorer/FileExplorer.tsx b/frontend/src/components/file-explorer/FileExplorer.tsx
index f13f0b8cf23a..6dc802334b50 100644
--- a/frontend/src/components/file-explorer/FileExplorer.tsx
+++ b/frontend/src/components/file-explorer/FileExplorer.tsx
@@ -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";
@@ -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;
}
@@ -34,6 +37,7 @@ function ExplorerActions({
toggleHidden,
onRefresh,
onUpload,
+ onDownload,
isHidden,
}: ExplorerActionsProps) {
return (
@@ -67,6 +71,17 @@ function ExplorerActions({
ariaLabel="Upload File"
onClick={onUpload}
/>
+
+ }
+ testId="download"
+ ariaLabel="Download Files"
+ onClick={onDownload}
+ />
>
)}
@@ -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(null);
@@ -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 ||
@@ -235,6 +259,12 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
)}
+ {isDownloading && (
+
+ )}
diff --git a/frontend/src/utils/download-files.ts b/frontend/src/utils/download-files.ts
new file mode 100644
index 000000000000..d805dc647ce1
--- /dev/null
+++ b/frontend/src/utils/download-files.ts
@@ -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 {
+ 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 {
+ 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);
+ }
+ }
+
+ 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);
+ throw new Error("Failed to download files");
+ }
+}
\ No newline at end of file