diff --git a/src/main/ipcHandlers/cloudExplorer.ipcHandlers.ts b/src/main/ipcHandlers/cloudExplorer.ipcHandlers.ts index 1dc6ec52..87028594 100644 --- a/src/main/ipcHandlers/cloudExplorer.ipcHandlers.ts +++ b/src/main/ipcHandlers/cloudExplorer.ipcHandlers.ts @@ -7,6 +7,7 @@ import type { DeleteObjectRequest, CreateFolderRequest, DeleteBucketRequest, + DownloadObjectRequest, } from '../../types/ipc'; import { CloudExplorerService, CloudPreviewService } from '../services'; @@ -22,6 +23,7 @@ const handlerChannels = [ 'cloudExplorer:createFolder', 'cloudExplorer:deleteBucket', 'cloudExplorer:uploadFolder', + 'cloudExplorer:downloadObject', ]; const removeCloudExplorerIpcHandlers = () => { @@ -276,6 +278,13 @@ const registerCloudExplorerHandlers = () => { return CloudExplorerService.uploadFolder(params, event.sender); }, ); + + ipcMain.handle( + 'cloudExplorer:downloadObject', + async (event, params: DownloadObjectRequest) => { + return CloudExplorerService.downloadObject(params, event.sender); + }, + ); }; export default registerCloudExplorerHandlers; diff --git a/src/main/services/cloudExplorer.service.ts b/src/main/services/cloudExplorer.service.ts index 27778621..8943410b 100644 --- a/src/main/services/cloudExplorer.service.ts +++ b/src/main/services/cloudExplorer.service.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import path from 'path'; import { Storage } from '@google-cloud/storage'; import { S3Client, @@ -26,7 +27,7 @@ import { BlobSASPermissions, SASProtocol, } from '@azure/storage-blob'; -import type { WebContents } from 'electron'; +import { net, IncomingMessage, type WebContents } from 'electron'; import { Bucket, StorageObject, @@ -55,6 +56,8 @@ import { CreateFolderResponse, DeleteBucketRequest, DeleteBucketResponse, + DownloadObjectRequest, + DownloadObjectResponse, UPLOAD_SIZE_LIMIT_BYTES, MULTIPART_THRESHOLD_BYTES, S3_BATCH_DELETE_LIMIT, @@ -1721,6 +1724,54 @@ class CloudExplorerService { } } + static async downloadObject( + { objectUrl, destinationPath }: DownloadObjectRequest, + webContents: WebContents, + ): Promise { + const downloadRequest = net.request(objectUrl); + const response: IncomingMessage = await new Promise((resolve, reject) => { + downloadRequest.on('response', resolve); + downloadRequest.on('error', reject); + downloadRequest.end(); + }); + if (response.statusCode !== 200) { + throw new Error(`Download error ${response.statusCode}`); + } + + const contentLength = Number(response.headers['content-length']); + const total = Number.isFinite(contentLength) ? contentLength : 0; + let loaded = 0; + const emitProgress = () => { + const percentage = total > 0 ? Math.round((loaded / total) * 100) : 0; + webContents.send('cloudExplorer:downloadProgress', { + loaded, + total, + percentage, + }); + }; + + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + const fileStream = fs.createWriteStream(destinationPath); + await new Promise((resolve, reject) => { + response.on('data', (chunk) => { + loaded += chunk.length; + fileStream.write(chunk); + emitProgress(); + }); + response.on('end', () => { + fileStream.end(); + resolve(); + }); + response.on('error', (err: Error) => { + fileStream.destroy(); + reject(err); + }); + fileStream.on('error', reject); + }); + + return { success: true, filePath: destinationPath }; + } + static async testConnection( provider: CloudProvider, config: CloudStorageConfig, diff --git a/src/renderer/components/cloudExplorer/ExplorerBucketContent.tsx b/src/renderer/components/cloudExplorer/ExplorerBucketContent.tsx index ef52e7b2..c4afc366 100644 --- a/src/renderer/components/cloudExplorer/ExplorerBucketContent.tsx +++ b/src/renderer/components/cloudExplorer/ExplorerBucketContent.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'react-toastify'; @@ -20,6 +20,7 @@ import { IconButton, InputBase, CircularProgress, + LinearProgress, Alert, Tooltip, Select, @@ -52,6 +53,7 @@ import { useConnection, useListObjects, useGetDownloadUrl, + useDownloadObject, useAddRecentItem, usePreviewData, } from '../../controllers/cloudExplorer.controller'; @@ -64,7 +66,7 @@ import useSecureStorage from '../../hooks/useSecureStorage'; import { formatFileSize, isPreviewSupported } from '../../utils/fileUtils'; import { DBTProjects } from '../sidebar/icons'; import { useGetSelectedProject } from '../../controllers'; -import { projectsServices } from '../../services'; +import { projectsServices, cloudExplorerService } from '../../services'; import bucketIcon from '../../../../assets/icons/bucket-blue.png'; import UploadDropzone from './UploadDropzone'; import DeleteConfirmDialog from './DeleteConfirmDialog'; @@ -92,6 +94,10 @@ export const ExplorerBucketContent: React.FC = ({ const [searchTerm, setSearchTerm] = useState(''); const [downloadUrls, setDownloadUrls] = useState>({}); const [loadingUrls, setLoadingUrls] = useState>({}); + const [downloadProgress, setDownloadProgress] = useState< + Record + >({}); + const activeDownloadObjectRef = useRef(null); const [previewFile, setPreviewFile] = useState<{ fileName: string; objectName: string; @@ -188,6 +194,18 @@ export const ExplorerBucketContent: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [connection]); + useEffect(() => { + const unsubscribe = cloudExplorerService.onDownloadProgress((event) => { + const objectName = activeDownloadObjectRef.current; + if (!objectName) return; + setDownloadProgress((prev) => ({ + ...prev, + [objectName]: event.percentage, + })); + }); + return unsubscribe; + }, []); + const objectsQuery = useListObjects( connection?.provider as CloudProvider, secureConfig as any, @@ -196,6 +214,7 @@ export const ExplorerBucketContent: React.FC = ({ !!connection && !!secureConfig, ); const getDownloadUrl = useGetDownloadUrl(); + const downloadObject = useDownloadObject(); const addRecentItem = useAddRecentItem(); const previewData = usePreviewData(); @@ -310,10 +329,44 @@ export const ExplorerBucketContent: React.FC = ({ } }; + const saveObjectToDisk = async (objectName: string, url: string) => { + const fileName = objectName.split('/').pop() || objectName; + const saveDialogResult = await window.electron.ipcRenderer.invoke( + 'dialog:showSaveDialog', + { + title: 'Save File', + defaultPath: fileName, + }, + ); + if (saveDialogResult.canceled || !saveDialogResult.filePath) return; + + activeDownloadObjectRef.current = objectName; + setDownloadProgress((prev) => ({ ...prev, [objectName]: 0 })); + try { + await downloadObject.mutateAsync({ + objectUrl: url, + destinationPath: saveDialogResult.filePath, + }); + toast.success(`File downloaded to ${saveDialogResult.filePath}`); + } finally { + activeDownloadObjectRef.current = null; + setDownloadProgress((prev) => { + const next = { ...prev }; + delete next[objectName]; + return next; + }); + } + }; + const handleDownload = async (objectName: string) => { if (downloadUrls[objectName]) { - window.open(downloadUrls[objectName], '_blank'); - toast.success('File download started successfully'); + try { + await saveObjectToDisk(objectName, downloadUrls[objectName]); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error downloading file:', error); + toast.error('Failed to download file. Please try again.'); + } return; } @@ -330,8 +383,7 @@ export const ExplorerBucketContent: React.FC = ({ }); if (url) { setDownloadUrls((prev) => ({ ...prev, [objectName]: url })); - window.open(url, '_blank'); - toast.success('File download started successfully'); + await saveObjectToDisk(objectName, url); // Add to recent items const fileName = objectName.split('/').pop() || objectName; @@ -590,22 +642,36 @@ export const ExplorerBucketContent: React.FC = ({ )} {!object.isDirectory && ( - - { - e.stopPropagation(); - handleDownload(object.name); - }} - disabled={loadingUrls[object.name]} - > - {loadingUrls[object.name] ? ( - - ) : ( - - )} - - + + + + { + e.stopPropagation(); + handleDownload(object.name); + }} + disabled={loadingUrls[object.name]} + > + {loadingUrls[object.name] && + downloadProgress[object.name] === undefined ? ( + + ) : ( + + )} + + + + {downloadProgress[object.name] !== undefined && ( + + + + )} + )} {!object.isDirectory && project && diff --git a/src/renderer/controllers/cloudExplorer.controller.ts b/src/renderer/controllers/cloudExplorer.controller.ts index b9fa8c91..0a04eda5 100644 --- a/src/renderer/controllers/cloudExplorer.controller.ts +++ b/src/renderer/controllers/cloudExplorer.controller.ts @@ -21,6 +21,7 @@ import type { CreateFolderResponse, DeleteBucketRequest, DeleteBucketResponse, + DownloadObjectRequest, } from '../../types/ipc'; export const cloudExplorerKeys = { @@ -116,6 +117,13 @@ export const useGetDownloadUrl = () => { ); }; +// Mutation for downloading an object to a local path +export const useDownloadObject = () => { + return useMutation((params: DownloadObjectRequest) => + cloudExplorerService.downloadObject(params), + ); +}; + // Mutation for previewing data export const usePreviewData = () => { return useMutation( diff --git a/src/renderer/services/cloudExplorer.service.ts b/src/renderer/services/cloudExplorer.service.ts index 6c635553..94ebf872 100644 --- a/src/renderer/services/cloudExplorer.service.ts +++ b/src/renderer/services/cloudExplorer.service.ts @@ -20,6 +20,9 @@ import type { CreateFolderResponse, DeleteBucketRequest, DeleteBucketResponse, + DownloadObjectRequest, + DownloadObjectResponse, + DownloadProgressEvent, } from '../../types/ipc'; import { client } from '../config/client'; @@ -188,6 +191,25 @@ class CloudExplorerService { >('cloudExplorer:deleteBucket', params); return data; } + + static async downloadObject( + params: DownloadObjectRequest, + ): Promise { + const { data } = await client.post< + DownloadObjectRequest, + DownloadObjectResponse + >('cloudExplorer:downloadObject', params); + return data; + } + + static onDownloadProgress( + handler: (event: DownloadProgressEvent) => void, + ): () => void { + return window.electron.ipcRenderer.on( + 'cloudExplorer:downloadProgress', + handler as (...args: unknown[]) => void, + ); + } } export default CloudExplorerService; diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 3bb7f378..6ca5e7d3 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -261,7 +261,9 @@ export type CloudExplorerChannels = | 'cloudExplorer:deleteObject' | 'cloudExplorer:uploadProgress' | 'cloudExplorer:createFolder' - | 'cloudExplorer:deleteBucket'; + | 'cloudExplorer:deleteBucket' + | 'cloudExplorer:downloadObject' + | 'cloudExplorer:downloadProgress'; export type DuckLakeChannels = // Extension Management @@ -524,6 +526,22 @@ export interface DeleteObjectResponse { deletedCount: number; } +export interface DownloadObjectRequest { + objectUrl: string; + destinationPath: string; +} + +export interface DownloadObjectResponse { + success: boolean; + filePath: string; +} + +export interface DownloadProgressEvent { + loaded: number; + total: number; + percentage: number; +} + export interface UploadProgressEvent { loaded: number; total: number;