-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement file-by-file download with progress
- 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
1 parent
be92965
commit 4c8c100
Showing
3 changed files
with
233 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { | ||
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 ( | ||
<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> | ||
</div> | ||
|
||
<div className="mb-4"> | ||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden"> | ||
<div | ||
className="h-full bg-blue-500 transition-all duration-300" | ||
style={{ | ||
width: `${(progress.filesDownloaded / progress.filesTotal) * 100}%` | ||
}} | ||
/> | ||
</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 | ||
onClick={() => abortController.current.abort()} | ||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800" | ||
> | ||
Cancel | ||
</button> | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
} | ||
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
|
||
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"); | ||
} | ||
} |