Skip to content

Commit 08c68db

Browse files
committed
feat(FR-84): add bulk file download as ZIP archive in vfolder explorer (#6153)
Resolves #2100 [FR-84](https://lablup.atlassian.net/browse/FR-84) ## Summary - Add bulk download button in vfolder explorer toolbar when files are selected - Use `POST /folders/{name}/request-download-archive` API to get download token, then download ZIP via storage proxy - Add `request_download_archive` method to backend client with `download-archive` feature flag (requires manager >= 26.3.0) - Add `targetVFolderName` to `FolderInfoContext` for custom ZIP filename (`vfolder-{name}-{timestamp}.zip`) - Add i18n keys for download button tooltip and success message ## Test plan - [ ] Select multiple files/folders in vfolder explorer and verify download button appears - [ ] Click download button and verify ZIP file downloads with correct name format - [ ] Verify download button is hidden on manager versions < 26.3.0 - [ ] Verify download button is disabled when user lacks download permission - [ ] Test download from subdirectory (non-root path) [FR-84]: https://lablup.atlassian.net/browse/FR-84?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 9f3cb82 commit 08c68db

30 files changed

Lines changed: 31882 additions & 39 deletions

packages/backend.ai-ui/src/components/baiClient/FileExplorer/BAIFileExplorer.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ import { useTranslation } from 'react-i18next';
3535

3636
export const FolderInfoContext = createContext<{
3737
targetVFolderId: string;
38+
targetVFolderName: string;
3839
currentPath: string;
3940
}>({
4041
targetVFolderId: '',
42+
targetVFolderName: '',
4143
currentPath: '.',
4244
});
4345

@@ -47,6 +49,7 @@ export interface BAIFileExplorerRef {
4749

4850
export interface BAIFileExplorerProps {
4951
targetVFolderId: string;
52+
targetVFolderName?: string;
5053
fetchKey?: string;
5154
onUpload: (files: Array<RcFile>, currentPath: string) => void;
5255
tableProps?: Partial<BAITableProps<VFolderFile>>;
@@ -70,6 +73,7 @@ export interface BAIFileExplorerProps {
7073

7174
const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
7275
targetVFolderId,
76+
targetVFolderName,
7377
fetchKey,
7478
onUpload,
7579
tableProps,
@@ -290,7 +294,13 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
290294
}, []);
291295

292296
return (
293-
<FolderInfoContext.Provider value={{ targetVFolderId, currentPath }}>
297+
<FolderInfoContext.Provider
298+
value={{
299+
targetVFolderId,
300+
targetVFolderName: targetVFolderName ?? '',
301+
currentPath,
302+
}}
303+
>
294304
{isDragMode && (
295305
<DragAndDrop
296306
portalContainer={fileDropContainerRef?.current || undefined}
@@ -313,6 +323,7 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
313323
/>
314324
<ExplorerActionControls
315325
selectedFiles={selectedItems}
326+
enableDownload={enableDownload}
316327
enableDelete={enableDelete}
317328
enableWrite={enableWrite}
318329
onUpload={(files, currentPath) => onUpload(files, currentPath)}

packages/backend.ai-ui/src/components/baiClient/FileExplorer/ExplorerActionControls.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { initiateDownload } from '../../../helper';
2+
import { useTanMutation } from '../../../helper/reactQueryAlias';
13
import { BAITrashBinIcon } from '../../../icons';
4+
import BAIButton from '../../BAIButton';
25
import BAIFlex from '../../BAIFlex';
36
import BAISelectionLabel from '../../BAISelectionLabel';
7+
import useConnectedBAIClient from '../../provider/BAIClientProvider/hooks/useConnectedBAIClient';
48
import { VFolderFile } from '../../provider/BAIClientProvider/types';
9+
import { FolderInfoContext } from './BAIFileExplorer';
510
import CreateDirectoryModal from './CreateDirectoryModal';
611
import CreateFileModal from './CreateFileModal';
712
import DeleteSelectedItemsModal, {
@@ -14,10 +19,11 @@ import {
1419
UploadOutlined,
1520
} from '@ant-design/icons';
1621
import { useToggle } from 'ahooks';
17-
import { Button, Dropdown, Grid, theme, Tooltip, Upload } from 'antd';
22+
import { App, Button, Dropdown, Grid, theme, Tooltip, Upload } from 'antd';
1823
import { createStyles } from 'antd-style';
1924
import type { RcFile } from 'antd/es/upload';
20-
import { useRef } from 'react';
25+
import { DownloadIcon } from 'lucide-react';
26+
import { use, useRef } from 'react';
2127
import { useTranslation } from 'react-i18next';
2228

2329
const useStyles = createStyles(({ css }) => ({
@@ -42,6 +48,7 @@ interface ExplorerActionControlsProps {
4248
onUpload: (files: Array<RcFile>, currentPath: string) => void;
4349
onDeleteFilesInBackground: DeleteSelectedItemsModalProps['onDeleteFilesInBackground'];
4450
onClearSelection?: () => void;
51+
enableDownload?: boolean;
4552
enableDelete?: boolean;
4653
enableWrite?: boolean;
4754
// onClickRefresh?: (key: string) => void;
@@ -54,6 +61,7 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
5461
onUpload,
5562
onDeleteFilesInBackground,
5663
onClearSelection,
64+
enableDownload = false,
5765
enableDelete = false,
5866
enableWrite = false,
5967
extra,
@@ -62,7 +70,11 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
6270
const { lg } = Grid.useBreakpoint();
6371
const { token } = theme.useToken();
6472
const { styles } = useStyles();
73+
const { message } = App.useApp();
6574
const { uploadFiles } = useUploadVFolderFiles();
75+
const { targetVFolderId, targetVFolderName, currentPath } =
76+
use(FolderInfoContext);
77+
const baiClient = useConnectedBAIClient();
6678
const [openUploadDropdown, { toggle: toggleUploadDropdown }] =
6779
useToggle(false);
6880
const [openCreateModal, { toggle: toggleCreateModal }] = useToggle(false);
@@ -71,6 +83,39 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
7183
const [openDeleteModal, { toggle: toggleDeleteModal }] = useToggle(false);
7284
const lastFileListRef = useRef<Array<RcFile>>([]);
7385

86+
const downloadArchiveMutation = useTanMutation({
87+
mutationFn: async (filePaths: Array<string>) => {
88+
const timestamp = new Date()
89+
.toISOString()
90+
.replace(/[-:]/g, '')
91+
.replace(/\.\d{3}/, '');
92+
const fileName = `vfolder-${targetVFolderName}-${timestamp}.zip`;
93+
94+
const tokenResponse = await baiClient.vfolder.request_download_archive(
95+
filePaths,
96+
targetVFolderId,
97+
fileName,
98+
);
99+
const downloadURL = `${tokenResponse.url}?token=${encodeURIComponent(tokenResponse.token)}`;
100+
101+
await initiateDownload(downloadURL, fileName);
102+
},
103+
onSuccess: () => {
104+
message.success(
105+
t('comp:FileExplorer.ArchiveDownloadStarted', {
106+
count: selectedFiles.length,
107+
}),
108+
);
109+
},
110+
onError: (err: any) => {
111+
if (err && err.message) {
112+
message.error(err.message);
113+
} else if (err && err.title) {
114+
message.error(err.title);
115+
}
116+
},
117+
});
118+
74119
return (
75120
<BAIFlex gap="xs">
76121
<BAIFlex gap={'sm'}>
@@ -89,6 +134,25 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
89134
}}
90135
/>
91136
</Tooltip>
137+
{baiClient.supports('download-archive') && (
138+
<Tooltip
139+
title={t('comp:FileExplorer.DownloadSelected')}
140+
placement="topLeft"
141+
>
142+
<BAIButton
143+
disabled={!enableDownload}
144+
icon={<DownloadIcon />}
145+
action={async () => {
146+
const filePaths = selectedFiles.map((file) =>
147+
currentPath === '.'
148+
? file.name
149+
: `${currentPath}/${file.name}`,
150+
);
151+
await downloadArchiveMutation.mutateAsync(filePaths);
152+
}}
153+
/>
154+
</Tooltip>
155+
)}
92156
</>
93157
)}
94158
<Tooltip title={!lg && t('comp:FileExplorer.CreateFolder')}>

packages/backend.ai-ui/src/components/baiClient/FileExplorer/FileItemControls.tsx

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { convertToBinaryUnit } from '../../../helper';
1+
import { convertToBinaryUnit, initiateDownload } from '../../../helper';
22
import { useTanMutation } from '../../../helper/reactQueryAlias';
33
import { BAITrashBinIcon } from '../../../icons';
44
import BAIButton, { BAIButtonProps } from '../../BAIButton';
@@ -182,38 +182,3 @@ const FileItemControls: React.FC<FileItemControlsProps> = ({
182182
};
183183

184184
export default FileItemControls;
185-
186-
/**
187-
*
188-
* @param downloadURL
189-
* @param fileName
190-
*/
191-
const initiateDownload = async (
192-
downloadURL: string,
193-
fileName: string,
194-
): Promise<void> => {
195-
return new Promise((resolve, reject) => {
196-
try {
197-
// @ts-ignore - iOS Safari
198-
if (globalThis.iOSSafari) {
199-
const newWindow = window.open(downloadURL, '_blank');
200-
newWindow && resolve();
201-
} else {
202-
const downloadLink = document.createElement('a');
203-
downloadLink.style.display = 'none';
204-
downloadLink.href = downloadURL;
205-
downloadLink.download = fileName;
206-
downloadLink.addEventListener('click', (e) => {
207-
e.stopPropagation();
208-
});
209-
document.body.appendChild(downloadLink);
210-
downloadLink.click();
211-
document.body.removeChild(downloadLink);
212-
213-
resolve();
214-
}
215-
} catch (error) {
216-
reject(error);
217-
}
218-
});
219-
};

packages/backend.ai-ui/src/components/provider/BAIClientProvider/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export interface BAIClient {
3333
name: string,
3434
archive?: boolean,
3535
) => Promise<VFolderDownloadToken>;
36+
request_download_archive: (
37+
files: Array<string>,
38+
name: string,
39+
filename?: string,
40+
) => Promise<VFolderDownloadToken>;
3641
create_upload_session: (
3742
path: string,
3843
fs: object,

packages/backend.ai-ui/src/helper/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,37 @@ export const useSemanticColorMap = (): Record<SemanticColor, string> => {
460460
default: token.colorBorder,
461461
};
462462
};
463+
464+
/**
465+
* Initiate a file download from a URL with a custom filename.
466+
* Handles iOS Safari separately by opening a new window.
467+
*/
468+
export const initiateDownload = async (
469+
downloadURL: string,
470+
fileName: string,
471+
): Promise<void> => {
472+
return new Promise((resolve, reject) => {
473+
try {
474+
// @ts-ignore - iOS Safari
475+
if (globalThis.iOSSafari) {
476+
const newWindow = window.open(downloadURL, '_blank');
477+
newWindow && resolve();
478+
} else {
479+
const downloadLink = document.createElement('a');
480+
downloadLink.style.display = 'none';
481+
downloadLink.href = downloadURL;
482+
downloadLink.download = fileName;
483+
downloadLink.addEventListener('click', (e) => {
484+
e.stopPropagation();
485+
});
486+
document.body.appendChild(downloadLink);
487+
downloadLink.click();
488+
document.body.removeChild(downloadLink);
489+
490+
resolve();
491+
}
492+
} catch (error) {
493+
reject(error);
494+
}
495+
});
496+
};

packages/backend.ai-ui/src/locale/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@
277277
"SelectFolder": "Ordner auswählen"
278278
},
279279
"comp:FileExplorer": {
280+
"ArchiveDownloadStarted": "Der Download von {{count}} Element(en) wurde gestartet.",
280281
"ChangeFileExtension": "Dateierweiterung ändern",
281282
"ChangeFileExtensionDesc": "Das Ändern der Dateierweiterung kann dazu führen, dass die Datei unbrauchbar oder falsch geöffnet wird. \nMöchten Sie fortfahren?",
282283
"Controls": "Kontrollen",
@@ -287,6 +288,7 @@
287288
"CreatedAt": "Erstellt at",
288289
"DeleteSelectedItemDesc": "Löschte Dateien und Ordner können nicht wiederhergestellt werden. \nMöchten Sie fortfahren?",
289290
"DeleteSelectedItemsDialog": "Bestätigung löschen",
291+
"DownloadSelected": "Herunterladen",
290292
"DownloadStarted": "Die Datei \"{{fileName}}\" wurde gestartet.",
291293
"DragAndDropDesc": "Ziehen Sie Dateien in diesen Bereich zum Hochladen.",
292294
"DuplicatedFiles": "Überschreibung der Bestätigung",

packages/backend.ai-ui/src/locale/el.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@
277277
"SelectFolder": "Επιλογή φακέλου"
278278
},
279279
"comp:FileExplorer": {
280+
"ArchiveDownloadStarted": "Ξεκίνησε η λήψη {{count}} στοιχείων.",
280281
"ChangeFileExtension": "Αλλαγή επέκτασης αρχείου",
281282
"ChangeFileExtensionDesc": "Η αλλαγή της επέκτασης του αρχείου μπορεί να προκαλέσει λανθασμένα το αρχείο. \nΘέλετε να προχωρήσετε;",
282283
"Controls": "Χειριστήρια",
@@ -287,6 +288,7 @@
287288
"CreatedAt": "Δημιουργήθηκε στο",
288289
"DeleteSelectedItemDesc": "Τα διαγραμμένα αρχεία και οι φάκελοι δεν μπορούν να αποκατασταθούν. \nΘέλετε να προχωρήσετε;",
289290
"DeleteSelectedItemsDialog": "Διαγραφή επιβεβαίωσης",
291+
"DownloadSelected": "Λήψη",
290292
"DownloadStarted": "Αρχείο \"{{fileName}}\" Η λήψη έχει ξεκινήσει.",
291293
"DragAndDropDesc": "Σύρετε και αποθέστε αρχεία σε αυτήν την περιοχή για μεταφόρτωση.",
292294
"DuplicatedFiles": "Αντιπροσώπηση επιβεβαίωσης",

packages/backend.ai-ui/src/locale/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@
283283
"SelectFolder": "Select Folder"
284284
},
285285
"comp:FileExplorer": {
286+
"ArchiveDownloadStarted": "Download of {{count}} item(s) has started.",
286287
"ChangeFileExtension": "Change File Extension",
287288
"ChangeFileExtensionDesc": "Changing the file extension may cause the file to become unusable or open incorrectly. Do you want to proceed?",
288289
"Controls": "Controls",
@@ -293,6 +294,7 @@
293294
"CreatedAt": "Created At",
294295
"DeleteSelectedItemDesc": "Deleted files and folders cannot be restored. Do you want to proceed?",
295296
"DeleteSelectedItemsDialog": "Delete Confirmation",
297+
"DownloadSelected": "Download",
296298
"DownloadStarted": "File \"{{fileName}}\" download has started.",
297299
"DragAndDropDesc": "Drag and drop files to this area to upload.",
298300
"DuplicatedFiles": "Overwrite Confirmation",

packages/backend.ai-ui/src/locale/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@
277277
"SelectFolder": "Seleccionar carpeta"
278278
},
279279
"comp:FileExplorer": {
280+
"ArchiveDownloadStarted": "Se ha iniciado la descarga de {{count}} elemento(s).",
280281
"ChangeFileExtension": "Cambiar la extensión del archivo",
281282
"ChangeFileExtensionDesc": "Cambiar la extensión del archivo puede hacer que el archivo se vuelva inutilizable o se abra incorrectamente. \n¿Quieres continuar?",
282283
"Controls": "Control",
@@ -287,6 +288,7 @@
287288
"CreatedAt": "Creado a",
288289
"DeleteSelectedItemDesc": "Los archivos y carpetas eliminados no se pueden restaurar. \n¿Quieres continuar?",
289290
"DeleteSelectedItemsDialog": "Eliminar confirmación",
291+
"DownloadSelected": "Descargar",
290292
"DownloadStarted": "El archivo \"{{fileName}}\" ha comenzado.",
291293
"DragAndDropDesc": "Arrastre y suelte archivos a esta área para cargar.",
292294
"DuplicatedFiles": "Confirmación de sobrescribencia",

packages/backend.ai-ui/src/locale/fi.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@
277277
"SelectFolder": "Valitse kansio"
278278
},
279279
"comp:FileExplorer": {
280+
"ArchiveDownloadStarted": "Lataus aloitettu: {{count}} kohdetta.",
280281
"ChangeFileExtension": "Vaihda tiedoston laajennus",
281282
"ChangeFileExtensionDesc": "Tiedoston laajennuksen muuttaminen voi aiheuttaa tiedoston käyttökelvottoman tai avoimen väärin. \nHaluatko edetä?",
282283
"Controls": "Hallintalaitteet",
@@ -287,6 +288,7 @@
287288
"CreatedAt": "Luotu jhk",
288289
"DeleteSelectedItemDesc": "Poistettuja tiedostoja ja kansioita ei voida palauttaa. \nHaluatko edetä?",
289290
"DeleteSelectedItemsDialog": "Poista vahvistus",
291+
"DownloadSelected": "Lataa",
290292
"DownloadStarted": "Tiedosto \"{{fileName}}\" lataus on alkanut.",
291293
"DragAndDropDesc": "Vedä ja pudota tiedostoja tälle alueelle ladataksesi.",
292294
"DuplicatedFiles": "Korvata vahvistus",

0 commit comments

Comments
 (0)