Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement file-by-file download with progress #5008

Merged
merged 52 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
4c8c100
Implement file-by-file download with progress
openhands-agent Nov 14, 2024
de504f0
Switch to direct file downloads without zipping
openhands-agent Nov 14, 2024
90f7991
Merge branch 'main' into feature/file-by-file-download
rbren Nov 21, 2024
97f5b53
fix lint
rbren Nov 21, 2024
d554f73
refactor: Fix linting issues in file download code
openhands-agent Nov 21, 2024
b3c084c
Merge branch 'main' into feature/file-by-file-download
rbren Dec 6, 2024
eea0132
first pass at integrating it
rbren Dec 6, 2024
d80cb46
refactor: separate download logic from DownloadProgress component
openhands-agent Dec 6, 2024
15c655a
fix: add initial progress state and example usage
openhands-agent Dec 6, 2024
f2fdb39
fix: update ProjectMenuCard to use new DownloadModal
openhands-agent Dec 6, 2024
aeb1701
fix path
rbren Dec 6, 2024
0b081df
fix bail out
rbren Dec 6, 2024
7cef997
rename hook
rbren Dec 6, 2024
b0fa602
refactor: remove individual file download fallback
openhands-agent Dec 6, 2024
6dd5c29
feat: show file discovery progress in download modal
openhands-agent Dec 6, 2024
631a146
fix: prevent multiple downloads running simultaneously
openhands-agent Dec 6, 2024
4e11e37
fix: start download immediately in useDownloadProgress hook
openhands-agent Dec 6, 2024
637e7a0
chore: add debug logs to track download lifecycle
openhands-agent Dec 6, 2024
a67fa6e
fix: separate AbortController lifecycle from download effect
openhands-agent Dec 6, 2024
3edb19f
fix: stabilize modal mounting and download start
openhands-agent Dec 6, 2024
4810848
fix: prevent download from starting on page load
openhands-agent Dec 6, 2024
c6f2a50
fix: prompt for download location before scanning files
openhands-agent Dec 6, 2024
5c2f102
fix: handle permission timeout after long file scan
openhands-agent Dec 6, 2024
a1e494b
fix: improve directory picker behavior and error handling
openhands-agent Dec 6, 2024
638bddf
fix: update download progress correctly by using shared progress object
openhands-agent Dec 6, 2024
22879a0
fix: ensure download progress updates trigger UI re-renders
openhands-agent Dec 6, 2024
1d30ae5
fix: properly transition from discovering to downloading state
openhands-agent Dec 6, 2024
3eadd2d
fix: track completed files correctly across batches
openhands-agent Dec 6, 2024
c089903
remove logs
rbren Dec 6, 2024
f7877c8
remove logs
rbren Dec 6, 2024
8b8d2b2
delint
rbren Dec 6, 2024
c1356e4
fix: remove unused imports and fix lint errors
openhands-agent Dec 6, 2024
72b00e4
delint a bit
rbren Dec 6, 2024
fd81acc
fix: update download modal styles to match dark theme
openhands-agent Dec 6, 2024
44d63ab
Merge branch 'feature/file-by-file-download' of ssh://github.com/all-…
rbren Dec 6, 2024
08f2e98
Fix TypeScript and linting issues in file download feature
openhands-agent Dec 6, 2024
376c434
Keep conversations around after detachment
openhands-agent Dec 6, 2024
90c88e7
remove some extra logic
rbren Dec 6, 2024
000cbf8
fix: preserve filesTotal count when transitioning from discovery to d…
openhands-agent Dec 6, 2024
840fcca
Merge branch 'feature/file-by-file-download' of ssh://github.com/all-…
rbren Dec 6, 2024
076ff30
Reuse active conversations before detaching them
openhands-agent Dec 6, 2024
1a03f0c
fix: preserve total file count when transitioning from discovery to d…
openhands-agent Dec 6, 2024
1b199a5
fix downloader
rbren Dec 6, 2024
faa02a8
Add thread safety for conversation dictionaries in SessionManager
openhands-agent Dec 11, 2024
ff9f6d0
Merge main branch and resolve conflicts
openhands-agent Dec 11, 2024
e817d16
Fix lint issues in download-files.ts:
openhands-agent Dec 11, 2024
413cc00
download files action button
rbren Dec 11, 2024
3dbd4d2
fix modal z index
rbren Dec 11, 2024
a025874
fix onClose
rbren Dec 11, 2024
a6a8842
Merge branch 'main' into feature/file-by-file-download
rbren Dec 11, 2024
c21cfc4
Merge branch 'main' into feature/file-by-file-download
rbren Dec 13, 2024
a890ccc
fix: remove unused imports in ProjectMenuCard
openhands-agent Dec 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions frontend/src/components/features/chat/action-suggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ? (
Expand Down Expand Up @@ -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);
}
}}
/>
)}
Expand Down
45 changes: 21 additions & 24 deletions frontend/src/components/features/project-menu/ProjectMenuCard.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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)}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/components/shared/download-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 87 additions & 0 deletions frontend/src/components/shared/download-progress.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
2 changes: 1 addition & 1 deletion frontend/src/components/shared/modals/modal-backdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/hooks/use-download-progress.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
6 changes: 3 additions & 3 deletions frontend/src/i18n/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading