mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Implement file-by-file download with progress (#5008)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
8488dd2a03
commit
bd3e38fe67
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
33
frontend/src/components/shared/download-modal.tsx
Normal file
33
frontend/src/components/shared/download-modal.tsx
Normal 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} />;
|
||||
}
|
||||
87
frontend/src/components/shared/download-progress.tsx
Normal file
87
frontend/src/components/shared/download-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
78
frontend/src/hooks/use-download-progress.ts
Normal file
78
frontend/src/hooks/use-download-progress.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
31
frontend/src/types/file-system.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
305
frontend/src/utils/download-files.ts
Normal file
305
frontend/src/utils/download-files.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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}",
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user