Implement file-by-file download with progress (#5008)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Robert Brennan 2024-12-18 08:37:43 -05:00 committed by GitHub
parent 8488dd2a03
commit bd3e38fe67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 644 additions and 55 deletions

View File

@ -2,7 +2,7 @@ import posthog from "posthog-js";
import React from "react";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { useAuth } from "#/context/auth-context";
import { downloadWorkspace } from "#/utils/download-workspace";
import { DownloadModal } from "#/components/shared/download-modal";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@ -16,19 +16,17 @@ export function ActionSuggestions({
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const handleDownloadWorkspace = async () => {
setIsDownloading(true);
try {
await downloadWorkspace();
} catch (error) {
// TODO: Handle error
} finally {
setIsDownloading(false);
}
const handleDownloadClose = () => {
setIsDownloading(false);
};
return (
<div className="flex flex-col gap-2 mb-2">
<DownloadModal
initialPath=""
onClose={handleDownloadClose}
isOpen={isDownloading}
/>
{gitHubToken ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
@ -75,13 +73,15 @@ export function ActionSuggestions({
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download .zip"
? "Download files"
: "Downloading, please wait...",
value: "Download .zip",
value: "Download files",
}}
onClick={() => {
posthog.capture("download_workspace_button_clicked");
handleDownloadWorkspace();
if (!isDownloading) {
setIsDownloading(true);
}
}}
/>
)}

View File

@ -1,16 +1,14 @@
import React from "react";
import toast from "react-hot-toast";
import posthog from "posthog-js";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { createChatMessage } from "#/services/chat-service";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details";
import { downloadWorkspace } from "#/utils/download-workspace";
import { useWsClient } from "#/context/ws-client-provider";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { DownloadModal } from "#/components/shared/download-modal";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@ -30,7 +28,7 @@ export function ProjectMenuCard({
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const [working, setWorking] = React.useState(false);
const [downloading, setDownloading] = React.useState(false);
const toggleMenuVisibility = () => {
setContextMenuIsOpen((prev) => !prev);
@ -58,20 +56,16 @@ Please push the changes to GitHub and open a pull request.
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
try {
setWorking(true);
downloadWorkspace().then(
() => setWorking(false),
() => setWorking(false),
);
} catch (error) {
toast.error("Failed to download workspace");
}
setDownloading(true);
};
const handleDownloadClose = () => {
setDownloading(false);
};
return (
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
{!working && contextMenuIsOpen && (
{!downloading && contextMenuIsOpen && (
<ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
@ -93,17 +87,20 @@ Please push the changes to GitHub and open a pull request.
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
/>
)}
<button
type="button"
onClick={toggleMenuVisibility}
aria-label="Open project menu"
>
{working ? (
<LoadingSpinner size="small" />
) : (
<DownloadModal
initialPath=""
onClose={handleDownloadClose}
isOpen={downloading}
/>
{!downloading && (
<button
type="button"
onClick={toggleMenuVisibility}
aria-label="Open project menu"
>
<EllipsisH width={36} height={36} />
)}
</button>
</button>
)}
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
<ConnectToGitHubModal

View File

@ -37,7 +37,7 @@ export function ProjectMenuCardContextMenu({
</ContextMenuListItem>
)}
<ContextMenuListItem onClick={onDownloadWorkspace}>
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)}
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)}
</ContextMenuListItem>
</ContextMenu>
);

View File

@ -0,0 +1,33 @@
import { useDownloadProgress } from "#/hooks/use-download-progress";
import { DownloadProgress } from "./download-progress";
interface DownloadModalProps {
initialPath: string;
onClose: () => void;
isOpen: boolean;
}
function ActiveDownload({
initialPath,
onClose,
}: {
initialPath: string;
onClose: () => void;
}) {
const { progress, cancelDownload } = useDownloadProgress(
initialPath,
onClose,
);
return <DownloadProgress progress={progress} onCancel={cancelDownload} />;
}
export function DownloadModal({
initialPath,
onClose,
isOpen,
}: DownloadModalProps) {
if (!isOpen) return null;
return <ActiveDownload initialPath={initialPath} onClose={onClose} />;
}

View File

@ -0,0 +1,87 @@
export interface DownloadProgressState {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
isDiscoveringFiles: boolean;
}
interface DownloadProgressProps {
progress: DownloadProgressState;
onCancel: () => void;
}
export function DownloadProgress({
progress,
onCancel,
}: DownloadProgressProps) {
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 += 1;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-20">
<div className="bg-[#1C1C1C] rounded-lg p-6 max-w-md w-full mx-4 border border-[#525252]">
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2 text-white">
{progress.isDiscoveringFiles
? "Preparing Download..."
: "Downloading Files"}
</h3>
<p className="text-sm text-gray-400 truncate">
{progress.isDiscoveringFiles
? `Found ${progress.filesTotal} files...`
: progress.currentFile}
</p>
</div>
<div className="mb-4">
<div className="h-2 bg-[#2C2C2C] rounded-full overflow-hidden">
{progress.isDiscoveringFiles ? (
<div
className="h-full bg-blue-500 animate-pulse"
style={{ width: "100%" }}
/>
) : (
<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-400">
<span>
{progress.isDiscoveringFiles
? `Scanning workspace...`
: `${progress.filesDownloaded} of ${progress.filesTotal} files`}
</span>
{!progress.isDiscoveringFiles && (
<span>{formatBytes(progress.bytesDownloadedPerSecond)}/s</span>
)}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@ -20,7 +20,7 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
};
return (
<div className="fixed inset-0 flex items-center justify-center z-10">
<div className="fixed inset-0 flex items-center justify-center z-20">
<div
onClick={handleClick}
className="fixed inset-0 bg-black bg-opacity-80"

View File

@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { downloadFiles } from "#/utils/download-files";
import { DownloadProgressState } from "#/components/shared/download-progress";
export const INITIAL_PROGRESS: DownloadProgressState = {
filesTotal: 0,
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0,
isDiscoveringFiles: true,
};
export function useDownloadProgress(
initialPath: string | undefined,
onClose: () => void,
) {
const [isStarted, setIsStarted] = useState(false);
const [progress, setProgress] =
useState<DownloadProgressState>(INITIAL_PROGRESS);
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
const abortController = useRef<AbortController>();
// Create AbortController on mount
useEffect(() => {
const controller = new AbortController();
abortController.current = controller;
// Initialize progress ref with initial state
progressRef.current = INITIAL_PROGRESS;
return () => {
controller.abort();
abortController.current = undefined;
};
}, []); // Empty deps array - only run on mount/unmount
// Start download when isStarted becomes true
useEffect(() => {
if (!isStarted) {
setIsStarted(true);
return;
}
if (!abortController.current) return;
// Start download
const download = async () => {
try {
await downloadFiles(initialPath, {
onProgress: (p) => {
// Update both the ref and state
progressRef.current = { ...p };
setProgress((prev: DownloadProgressState) => ({ ...prev, ...p }));
},
signal: abortController.current!.signal,
});
onClose();
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
onClose();
} else {
throw error;
}
}
};
download();
}, [initialPath, onClose, isStarted]);
// No longer need startDownload as it's handled in useEffect
const cancelDownload = useCallback(() => {
abortController.current?.abort();
}, []);
return {
progress,
cancelDownload,
};
}

View File

@ -2001,9 +2001,9 @@
"en": "Push to GitHub",
"es": "Subir a GitHub"
},
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL": {
"en": "Download as .zip",
"es": "Descargar como .zip"
"PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL": {
"en": "Download files",
"es": "Descargar archivos"
},
"ACTION_MESSAGE$RUN": {
"en": "Running a bash command"

31
frontend/src/types/file-system.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
interface FileSystemWritableFileStream extends WritableStream {
write(data: BufferSource | Blob | string): Promise<void>;
seek(position: number): Promise<void>;
truncate(size: number): Promise<void>;
}
interface FileSystemFileHandle {
kind: "file";
name: string;
getFile(): Promise<File>;
createWritable(options?: {
keepExistingData?: boolean;
}): Promise<FileSystemWritableFileStream>;
}
interface FileSystemDirectoryHandle {
kind: "directory";
name: string;
getDirectoryHandle(
name: string,
options?: { create?: boolean },
): Promise<FileSystemDirectoryHandle>;
getFileHandle(
name: string,
options?: { create?: boolean },
): Promise<FileSystemFileHandle>;
}
interface Window {
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
}

View File

@ -0,0 +1,305 @@
import OpenHands from "#/api/open-hands";
interface DownloadProgress {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
isDiscoveringFiles: boolean;
}
interface DownloadOptions {
onProgress?: (progress: DownloadProgress) => void;
signal?: AbortSignal;
}
/**
* Checks if the File System Access API is supported
*/
function isFileSystemAccessSupported(): boolean {
return "showDirectoryPicker" in window;
}
/**
* Creates subdirectories and returns the final directory handle
*/
async function createSubdirectories(
baseHandle: FileSystemDirectoryHandle,
pathParts: string[],
): Promise<FileSystemDirectoryHandle> {
return pathParts.reduce(async (promise, part) => {
const handle = await promise;
return handle.getDirectoryHandle(part, { create: true });
}, Promise.resolve(baseHandle));
}
/**
* Recursively gets all files in a directory
*/
async function getAllFiles(
path: string,
progress: DownloadProgress,
options?: DownloadOptions,
): Promise<string[]> {
const entries = await OpenHands.getFiles(path);
const processEntry = async (entry: string): Promise<string[]> => {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}
const fullPath = path + entry;
if (entry.endsWith("/")) {
const subEntries = await OpenHands.getFiles(fullPath);
const subFilesPromises = subEntries.map((subEntry) =>
processEntry(subEntry),
);
const subFilesArrays = await Promise.all(subFilesPromises);
return subFilesArrays.flat();
}
const updatedProgress = {
...progress,
filesTotal: progress.filesTotal + 1,
currentFile: fullPath,
};
options?.onProgress?.(updatedProgress);
return [fullPath];
};
const filePromises = entries.map((entry) => processEntry(entry));
const fileArrays = await Promise.all(filePromises);
const updatedProgress = {
...progress,
isDiscoveringFiles: false,
};
options?.onProgress?.(updatedProgress);
return fileArrays.flat();
}
/**
* Process a batch of files
*/
async function processBatch(
batch: string[],
directoryHandle: FileSystemDirectoryHandle,
progress: DownloadProgress,
startTime: number,
completedFiles: number,
totalBytes: number,
options?: DownloadOptions,
): Promise<{ newCompleted: number; newBytes: number }> {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}
// Process files in the batch in parallel
const results = await Promise.all(
batch.map(async (path) => {
try {
const newProgress = {
...progress,
currentFile: path,
isDiscoveringFiles: false,
filesDownloaded: completedFiles,
totalBytesDownloaded: totalBytes,
bytesDownloadedPerSecond:
totalBytes / ((Date.now() - startTime) / 1000),
};
options?.onProgress?.(newProgress);
const content = await OpenHands.getFile(path);
// Save to the selected directory preserving structure
const pathParts = path.split("/").filter(Boolean);
const fileName = pathParts.pop() || "file";
const dirHandle =
pathParts.length > 0
? await createSubdirectories(directoryHandle, pathParts)
: directoryHandle;
// Create and write the file
const fileHandle = await dirHandle.getFileHandle(fileName, {
create: true,
});
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
// Return the size of this file
return new Blob([content]).size;
} catch (error) {
// Silently handle file processing errors and return 0 bytes
return 0;
}
}),
);
// Calculate batch totals
const batchBytes = results.reduce((sum, size) => sum + size, 0);
const newTotalBytes = totalBytes + batchBytes;
const newCompleted =
completedFiles + results.filter((size) => size > 0).length;
// Update progress with batch results
const updatedProgress = {
...progress,
filesDownloaded: newCompleted,
totalBytesDownloaded: newTotalBytes,
bytesDownloadedPerSecond: newTotalBytes / ((Date.now() - startTime) / 1000),
isDiscoveringFiles: false,
};
options?.onProgress?.(updatedProgress);
return {
newCompleted,
newBytes: newTotalBytes,
};
}
/**
* 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, // Will be updated during file discovery
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0,
isDiscoveringFiles: true,
};
try {
// Check if File System Access API is supported
if (!isFileSystemAccessSupported()) {
throw new Error(
"Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.",
);
}
// Show directory picker first
let directoryHandle: FileSystemDirectoryHandle;
try {
directoryHandle = await window.showDirectoryPicker();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Download cancelled");
}
if (error instanceof Error && error.name === "SecurityError") {
throw new Error(
"Permission denied. Please allow access to the download location when prompted.",
);
}
throw new Error("Failed to select download location. Please try again.");
}
// Then recursively get all files
const files = await getAllFiles(initialPath || "", progress, options);
// Set isDiscoveringFiles to false now that we have the full list and preserve filesTotal
const finalTotal = progress.filesTotal;
options?.onProgress?.({
...progress,
filesTotal: finalTotal,
isDiscoveringFiles: false,
});
// Verify we still have permission after the potentially long file scan
try {
// Try to create and write to a test file to verify permissions
const testHandle = await directoryHandle.getFileHandle(
".openhands-test",
{ create: true },
);
const writable = await testHandle.createWritable();
await writable.close();
} catch (error) {
if (
error instanceof Error &&
error.message.includes("User activation is required")
) {
// Ask for permission again
try {
directoryHandle = await window.showDirectoryPicker();
} catch (permissionError) {
if (
permissionError instanceof Error &&
permissionError.name === "AbortError"
) {
throw new Error("Download cancelled");
}
if (
permissionError instanceof Error &&
permissionError.name === "SecurityError"
) {
throw new Error(
"Permission denied. Please allow access to the download location when prompted.",
);
}
throw new Error(
"Failed to select download location. Please try again.",
);
}
} else {
throw error;
}
}
// Process files in parallel batches to avoid overwhelming the browser
const BATCH_SIZE = 5;
const batches = Array.from(
{ length: Math.ceil(files.length / BATCH_SIZE) },
(_, i) => files.slice(i * BATCH_SIZE, (i + 1) * BATCH_SIZE),
);
// Keep track of completed files across all batches
let completedFiles = 0;
let totalBytesDownloaded = 0;
// Process batches sequentially to maintain order and avoid overwhelming the browser
await batches.reduce(
(promise, batch) =>
promise.then(async () => {
const { newCompleted, newBytes } = await processBatch(
batch,
directoryHandle,
progress,
startTime,
completedFiles,
totalBytesDownloaded,
options,
);
completedFiles = newCompleted;
totalBytesDownloaded = newBytes;
}),
Promise.resolve(),
);
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
throw error;
}
// Re-throw the error as is if it's already a user-friendly message
if (
error instanceof Error &&
(error.message.includes("browser doesn't support") ||
error.message.includes("Failed to select") ||
error.message === "Download cancelled")
) {
throw error;
}
// Otherwise, wrap it with a generic message
throw new Error(
`Failed to download files: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@ -1,7 +1,6 @@
/** @type {import('tailwindcss').Config} */
import { nextui } from "@nextui-org/react";
import typography from '@tailwindcss/typography';
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",

View File

@ -29,6 +29,14 @@ class SessionManager:
_last_alive_timestamps: dict[str, float] = field(default_factory=dict)
_redis_listen_task: asyncio.Task | None = None
_session_is_running_flags: dict[str, asyncio.Event] = field(default_factory=dict)
_active_conversations: dict[str, tuple[Conversation, int]] = field(
default_factory=dict
)
_detached_conversations: dict[str, tuple[Conversation, float]] = field(
default_factory=dict
)
_conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_cleanup_task: asyncio.Task | None = None
_has_remote_connections_flags: dict[str, asyncio.Event] = field(
default_factory=dict
)
@ -37,12 +45,16 @@ class SessionManager:
redis_client = self._get_redis_client()
if redis_client:
self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
self._cleanup_task = asyncio.create_task(self._cleanup_detached_conversations())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._redis_listen_task:
self._redis_listen_task.cancel()
self._redis_listen_task = None
if self._cleanup_task:
self._cleanup_task.cancel()
self._cleanup_task = None
def _get_redis_client(self):
redis_client = getattr(self.sio.manager, 'redis', None)
@ -128,21 +140,68 @@ class SessionManager:
start_time = time.time()
if not await session_exists(sid, self.file_store):
return None
c = Conversation(sid, file_store=self.file_store, config=self.config)
try:
await c.connect()
except RuntimeUnavailableError as e:
logger.error(f'Error connecting to conversation {c.sid}: {e}')
return None
end_time = time.time()
logger.info(
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
)
return c
async with self._conversations_lock:
# Check if we have an active conversation we can reuse
if sid in self._active_conversations:
conversation, count = self._active_conversations[sid]
self._active_conversations[sid] = (conversation, count + 1)
logger.info(f'Reusing active conversation {sid}')
return conversation
# Check if we have a detached conversation we can reuse
if sid in self._detached_conversations:
conversation, _ = self._detached_conversations.pop(sid)
self._active_conversations[sid] = (conversation, 1)
logger.info(f'Reusing detached conversation {sid}')
return conversation
# Create new conversation if none exists
c = Conversation(sid, file_store=self.file_store, config=self.config)
try:
await c.connect()
except RuntimeUnavailableError as e:
logger.error(f'Error connecting to conversation {c.sid}: {e}')
return None
end_time = time.time()
logger.info(
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
)
self._active_conversations[sid] = (c, 1)
return c
async def detach_from_conversation(self, conversation: Conversation):
await conversation.disconnect()
sid = conversation.sid
async with self._conversations_lock:
if sid in self._active_conversations:
conv, count = self._active_conversations[sid]
if count > 1:
self._active_conversations[sid] = (conv, count - 1)
return
else:
self._active_conversations.pop(sid)
self._detached_conversations[sid] = (conversation, time.time())
async def _cleanup_detached_conversations(self):
while should_continue():
try:
async with self._conversations_lock:
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._detached_conversations.items())
for sid, (conversation, detach_time) in items:
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
await asyncio.sleep(60)
except asyncio.CancelledError:
async with self._conversations_lock:
for conversation, _ in self._detached_conversations.values():
await conversation.disconnect()
self._detached_conversations.clear()
return
except Exception:
logger.warning('error_cleaning_detached_conversations', exc_info=True)
await asyncio.sleep(15)
async def init_or_join_session(
self, sid: str, connection_id: str, session_init_data: SessionInitData
):