Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions src/main/ipcHandlers/cloudExplorer.ipcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
DeleteObjectRequest,
CreateFolderRequest,
DeleteBucketRequest,
DownloadObjectRequest,
} from '../../types/ipc';
import { CloudExplorerService, CloudPreviewService } from '../services';

Expand All @@ -22,6 +23,7 @@ const handlerChannels = [
'cloudExplorer:createFolder',
'cloudExplorer:deleteBucket',
'cloudExplorer:uploadFolder',
'cloudExplorer:downloadObject',
];

const removeCloudExplorerIpcHandlers = () => {
Expand Down Expand Up @@ -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;
53 changes: 52 additions & 1 deletion src/main/services/cloudExplorer.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'fs';
import path from 'path';
import { Storage } from '@google-cloud/storage';
import {
S3Client,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -55,6 +56,8 @@ import {
CreateFolderResponse,
DeleteBucketRequest,
DeleteBucketResponse,
DownloadObjectRequest,
DownloadObjectResponse,
UPLOAD_SIZE_LIMIT_BYTES,
MULTIPART_THRESHOLD_BYTES,
S3_BATCH_DELETE_LIMIT,
Expand Down Expand Up @@ -1721,6 +1724,54 @@ class CloudExplorerService {
}
}

static async downloadObject(
{ objectUrl, destinationPath }: DownloadObjectRequest,
webContents: WebContents,
): Promise<DownloadObjectResponse> {
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<void>((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,
Expand Down
110 changes: 88 additions & 22 deletions src/renderer/components/cloudExplorer/ExplorerBucketContent.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +20,7 @@ import {
IconButton,
InputBase,
CircularProgress,
LinearProgress,
Alert,
Tooltip,
Select,
Expand Down Expand Up @@ -52,6 +53,7 @@ import {
useConnection,
useListObjects,
useGetDownloadUrl,
useDownloadObject,
useAddRecentItem,
usePreviewData,
} from '../../controllers/cloudExplorer.controller';
Expand All @@ -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';
Expand Down Expand Up @@ -92,6 +94,10 @@ export const ExplorerBucketContent: React.FC<ExplorerBucketContentProps> = ({
const [searchTerm, setSearchTerm] = useState('');
const [downloadUrls, setDownloadUrls] = useState<Record<string, string>>({});
const [loadingUrls, setLoadingUrls] = useState<Record<string, boolean>>({});
const [downloadProgress, setDownloadProgress] = useState<
Record<string, number>
>({});
const activeDownloadObjectRef = useRef<string | null>(null);
const [previewFile, setPreviewFile] = useState<{
fileName: string;
objectName: string;
Expand Down Expand Up @@ -188,6 +194,18 @@ export const ExplorerBucketContent: React.FC<ExplorerBucketContentProps> = ({
// 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,
Expand All @@ -196,6 +214,7 @@ export const ExplorerBucketContent: React.FC<ExplorerBucketContentProps> = ({
!!connection && !!secureConfig,
);
const getDownloadUrl = useGetDownloadUrl();
const downloadObject = useDownloadObject();
const addRecentItem = useAddRecentItem();
const previewData = usePreviewData();

Expand Down Expand Up @@ -310,10 +329,44 @@ export const ExplorerBucketContent: React.FC<ExplorerBucketContentProps> = ({
}
};

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;
}

Expand All @@ -330,8 +383,7 @@ export const ExplorerBucketContent: React.FC<ExplorerBucketContentProps> = ({
});
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;
Expand Down Expand Up @@ -590,22 +642,36 @@ export const ExplorerBucketContent: React.FC<ExplorerBucketContentProps> = ({
</Tooltip>
)}
{!object.isDirectory && (
<Tooltip title="Download">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleDownload(object.name);
}}
disabled={loadingUrls[object.name]}
>
{loadingUrls[object.name] ? (
<CircularProgress size={16} />
) : (
<Download fontSize="small" />
)}
</IconButton>
</Tooltip>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title="Download">
<span>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleDownload(object.name);
}}
disabled={loadingUrls[object.name]}
>
{loadingUrls[object.name] &&
downloadProgress[object.name] === undefined ? (
<CircularProgress size={16} />
) : (
<Download fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
{downloadProgress[object.name] !== undefined && (
<Box sx={{ width: 60, ml: 0.5 }}>
<LinearProgress
variant="determinate"
value={downloadProgress[object.name]}
aria-label={`Download progress: ${downloadProgress[object.name]}%`}
/>
</Box>
)}
</Box>
)}
{!object.isDirectory &&
project &&
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/controllers/cloudExplorer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
CreateFolderResponse,
DeleteBucketRequest,
DeleteBucketResponse,
DownloadObjectRequest,
} from '../../types/ipc';

export const cloudExplorerKeys = {
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/services/cloudExplorer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type {
CreateFolderResponse,
DeleteBucketRequest,
DeleteBucketResponse,
DownloadObjectRequest,
DownloadObjectResponse,
DownloadProgressEvent,
} from '../../types/ipc';
import { client } from '../config/client';

Expand Down Expand Up @@ -188,6 +191,25 @@ class CloudExplorerService {
>('cloudExplorer:deleteBucket', params);
return data;
}

static async downloadObject(
params: DownloadObjectRequest,
): Promise<DownloadObjectResponse> {
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;
20 changes: 19 additions & 1 deletion src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading