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