diff --git a/frontend/src/components/features/chat/action-suggestions.tsx b/frontend/src/components/features/chat/action-suggestions.tsx index 28feab537b83..2a7e51479cb7 100644 --- a/frontend/src/components/features/chat/action-suggestions.tsx +++ b/frontend/src/components/features/chat/action-suggestions.tsx @@ -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 (
+ {gitHubToken ? (
{!hasPullRequest ? ( @@ -75,13 +73,15 @@ export function ActionSuggestions({ { posthog.capture("download_workspace_button_clicked"); - handleDownloadWorkspace(); + if (!isDownloading) { + setIsDownloading(true); + } }} /> )} diff --git a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx index bb4074907682..54512bd141e9 100644 --- a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx +++ b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx @@ -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 (
- {!working && contextMenuIsOpen && ( + {!downloading && contextMenuIsOpen && ( setConnectToGitHubModalOpen(true)} @@ -93,17 +87,20 @@ Please push the changes to GitHub and open a pull request. onConnectToGitHub={() => setConnectToGitHubModalOpen(true)} /> )} - + + )} {connectToGitHubModalOpen && ( setConnectToGitHubModalOpen(false)}> )} - {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_AS_ZIP_LABEL)} + {t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)} ); diff --git a/frontend/src/components/shared/download-modal.tsx b/frontend/src/components/shared/download-modal.tsx new file mode 100644 index 000000000000..0d2b92d4fcb3 --- /dev/null +++ b/frontend/src/components/shared/download-modal.tsx @@ -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 ; +} + +export function DownloadModal({ + initialPath, + onClose, + isOpen, +}: DownloadModalProps) { + if (!isOpen) return null; + + return ; +} diff --git a/frontend/src/components/shared/download-progress.tsx b/frontend/src/components/shared/download-progress.tsx new file mode 100644 index 000000000000..d5d79d0c6b43 --- /dev/null +++ b/frontend/src/components/shared/download-progress.tsx @@ -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 ( +
+
+
+

+ {progress.isDiscoveringFiles + ? "Preparing Download..." + : "Downloading Files"} +

+

+ {progress.isDiscoveringFiles + ? `Found ${progress.filesTotal} files...` + : progress.currentFile} +

+
+ +
+
+ {progress.isDiscoveringFiles ? ( +
+ ) : ( +
+ )} +
+
+ +
+ + {progress.isDiscoveringFiles + ? `Scanning workspace...` + : `${progress.filesDownloaded} of ${progress.filesTotal} files`} + + {!progress.isDiscoveringFiles && ( + {formatBytes(progress.bytesDownloadedPerSecond)}/s + )} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/shared/modals/modal-backdrop.tsx b/frontend/src/components/shared/modals/modal-backdrop.tsx index d10eb6ac7b9b..5d3fcea43217 100644 --- a/frontend/src/components/shared/modals/modal-backdrop.tsx +++ b/frontend/src/components/shared/modals/modal-backdrop.tsx @@ -20,7 +20,7 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) { }; return ( -
+
void, +) { + const [isStarted, setIsStarted] = useState(false); + const [progress, setProgress] = + useState(INITIAL_PROGRESS); + const progressRef = useRef(INITIAL_PROGRESS); + const abortController = useRef(); + + // 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, + }; +} diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index c86c62f2f55c..df5f4de4154e 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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" diff --git a/frontend/src/types/file-system.d.ts b/frontend/src/types/file-system.d.ts new file mode 100644 index 000000000000..c2d823f701e0 --- /dev/null +++ b/frontend/src/types/file-system.d.ts @@ -0,0 +1,31 @@ +interface FileSystemWritableFileStream extends WritableStream { + write(data: BufferSource | Blob | string): Promise; + seek(position: number): Promise; + truncate(size: number): Promise; +} + +interface FileSystemFileHandle { + kind: "file"; + name: string; + getFile(): Promise; + createWritable(options?: { + keepExistingData?: boolean; + }): Promise; +} + +interface FileSystemDirectoryHandle { + kind: "directory"; + name: string; + getDirectoryHandle( + name: string, + options?: { create?: boolean }, + ): Promise; + getFileHandle( + name: string, + options?: { create?: boolean }, + ): Promise; +} + +interface Window { + showDirectoryPicker(): Promise; +} diff --git a/frontend/src/utils/download-files.ts b/frontend/src/utils/download-files.ts new file mode 100644 index 000000000000..b22ebf47bdda --- /dev/null +++ b/frontend/src/utils/download-files.ts @@ -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 { + 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 { + const entries = await OpenHands.getFiles(path); + + const processEntry = async (entry: string): Promise => { + 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 { + 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)}`, + ); + } +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 1a57ebcd8bce..7af574f19e12 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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}", diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py index fbcf4eaa836a..a8ae543b8751 100644 --- a/openhands/server/session/manager.py +++ b/openhands/server/session/manager.py @@ -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 @@ async def __aenter__(self): 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 @@ async def attach_to_conversation(self, sid: str) -> Conversation | None: 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 def detach_from_conversation(self, conversation: Conversation): - await conversation.disconnect() + 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): + 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 ):