diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index b1e8652a0..569d20e66 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -315,6 +315,10 @@ "open": "Open", "openParentFolder": "Go to parent folder", "download": "Download", + "downloadAll": "Download all", + "downloadFolder": "Download folder", + "filesCount": "files", + "foldersCount": "folders", "batchDownload": "Download in batch", "share": "Share", "rename": "Rename", @@ -410,6 +414,7 @@ "browserDownloadDescription": "Your browser downloads files one by one and retain the folder structure to the local directory you specified.", "browserBatchDownload": "Browser-side archiving", "browserBatchDownloadDescription": "Downloaded and packaged to a Zip file by the browser in real time, it cannot handle data more than 4GB.", + "browserBatchDownloadSizeExceededDescription": "Total size {{size}} exceeds the 4GB browser-side archiving limit.", "serverBatchDownload": "Server-side archiving", "serverBatchDownloadDescription": "Archive by the server to a Zip file and sent to the client for download on-the-fly, share link shortcut is not supported.", "selectArchiveMethod": "Select archive method", diff --git a/src/component/Dialogs/SelectOption.tsx b/src/component/Dialogs/SelectOption.tsx index 74ccf8921..fc181dc21 100644 --- a/src/component/Dialogs/SelectOption.tsx +++ b/src/component/Dialogs/SelectOption.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { DialogContent, List, ListItemButton, ListItemText } from "@mui/material"; +import { DialogContent, List, ListItemButton, ListItemText, Typography } from "@mui/material"; import { useAppDispatch, useAppSelector } from "../../redux/hooks.ts"; import React, { useCallback } from "react"; import DraggableDialog from "./DraggableDialog.tsx"; @@ -12,6 +12,7 @@ const SelectOption = () => { const open = useAppSelector((state) => state.globalState.selectOptionDialogOpen); const title = useAppSelector((state) => state.globalState.selectOptionTitle); + const subtitle = useAppSelector((state) => state.globalState.selectOptionSubtitle); const promiseId = useAppSelector((state) => state.globalState.selectOptionPromiseId); const options = useAppSelector((state) => state.globalState.selectOptionDialogOptions); @@ -34,7 +35,16 @@ const SelectOption = () => { return ( + {t(title ?? "")} + {subtitle && ( + + {subtitle} + + )} + + } dialogProps={{ open: open ?? false, onClose: onClose, @@ -44,7 +54,7 @@ const SelectOption = () => { {options?.map((o) => ( - onAccept(o.value)}> + onAccept(o.value)}> { dispatch(inverseSelection(fmIndex)); }, [dispatch, onClose, fmIndex]); + const onDownloadAllClicked = useCallback(() => { + onClose && onClose({}, "escapeKeyDown"); + dispatch(downloadAllFiles(fmIndex)); + }, [dispatch, onClose, fmIndex]); + const onRefreshClicked = useCallback(() => { onClose && onClose({}, "escapeKeyDown"); dispatch(refreshFileList(fmIndex)); @@ -126,6 +133,17 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => { {t("application:fileManager.invertSelection")} + {fs != Filesystem.trash && fs != Filesystem.share && ( + <> + + + + + + {t("application:fileManager.downloadAll")} + + + )} ); }; diff --git a/src/component/FileManager/TopBar/NavHeader.tsx b/src/component/FileManager/TopBar/NavHeader.tsx index a86bf58e5..f3bb33c01 100644 --- a/src/component/FileManager/TopBar/NavHeader.tsx +++ b/src/component/FileManager/TopBar/NavHeader.tsx @@ -1,13 +1,27 @@ -import { Stack, useMediaQuery, useTheme } from "@mui/material"; -import Breadcrumb from "./Breadcrumb.tsx"; -import TopActions from "./TopActions.tsx"; +import { Collapse, Stack, useMediaQuery, useTheme } from "@mui/material"; +import { useContext } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { downloadAllFiles } from "../../../redux/thunks/download.ts"; +import { Filesystem } from "../../../util/uri.ts"; import { RadiusFrame } from "../../Frame/RadiusFrame.tsx"; -import TopActionsSecondary from "./TopActionsSecondary.tsx"; +import Download from "../../Icons/Download.tsx"; +import { FmIndexContext } from "../FmIndexContext.tsx"; import { SearchIndicator } from "../Search/SearchIndicator.tsx"; +import Breadcrumb from "./Breadcrumb.tsx"; +import TopActions, { ActionButton, ActionButtonGroup } from "./TopActions.tsx"; +import TopActionsSecondary from "./TopActionsSecondary.tsx"; const NavHeader = () => { const theme = useTheme(); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const fmIndex = useContext(FmIndexContext); + const fs = useAppSelector((state) => state.fileManager[fmIndex].current_fs); + const isSingleFileView = useAppSelector((state) => state.fileManager[fmIndex].list?.single_file_view); + const hasSelection = useAppSelector((state) => Object.keys(state.fileManager[fmIndex].selected).length > 0); + const showDownloadFolder = fs == Filesystem.share && !isSingleFileView; return ( { + + + + } + onClick={() => dispatch(downloadAllFiles(fmIndex))} + sx={{ color: "primary.main", whiteSpace: "nowrap" }} + > + {isMobile ? : t("application:fileManager.downloadFolder")} + + + + ); }; diff --git a/src/redux/globalStateSlice.ts b/src/redux/globalStateSlice.ts index 6d8a87477..9fddf8b2a 100644 --- a/src/redux/globalStateSlice.ts +++ b/src/redux/globalStateSlice.ts @@ -79,6 +79,7 @@ export interface DialogSelectOption { name: string; description: string; value: any; + disabled?: boolean; } export interface DesktopCallbackState { @@ -178,6 +179,7 @@ export interface GlobalStateSlice { selectOptionDialogOptions?: DialogSelectOption[]; selectOptionPromiseId?: string; selectOptionTitle?: string; + selectOptionSubtitle?: string; // Batch download log dialog batchDownloadLogDialogOpen?: boolean; @@ -514,12 +516,14 @@ export const globalStateSlice = createSlice({ options?: DialogSelectOption[]; promiseId: string; title?: string; + subtitle?: string; }>, ) => { state.selectOptionDialogOpen = action.payload.open; state.selectOptionDialogOptions = action.payload.options; state.selectOptionPromiseId = action.payload.promiseId; state.selectOptionTitle = action.payload.title; + state.selectOptionSubtitle = action.payload.subtitle; }, closeSelectOptionDialog: (state) => { state.selectOptionDialogOpen = false; diff --git a/src/redux/thunks/dialog.ts b/src/redux/thunks/dialog.ts index e880c40dc..6089c5388 100644 --- a/src/redux/thunks/dialog.ts +++ b/src/redux/thunks/dialog.ts @@ -249,7 +249,11 @@ export function requestCreateNew(fmIndex: number, type: string, defaultName?: st }; } -export function selectOption(options: DialogSelectOption[], title: string): AppThunk | Promise> { +export function selectOption( + options: DialogSelectOption[], + title: string, + subtitle?: string, +): AppThunk | Promise> { return async (dispatch) => { const id = promiseId(); return new Promise((resolve, reject) => { @@ -260,6 +264,7 @@ export function selectOption(options: DialogSelectOption[], title: string): AppT title, options, promiseId: id, + subtitle, }), ); }); diff --git a/src/redux/thunks/download.ts b/src/redux/thunks/download.ts index 64a816d1d..9f9cfede3 100644 --- a/src/redux/thunks/download.ts +++ b/src/redux/thunks/download.ts @@ -2,8 +2,8 @@ import dayjs from "dayjs"; import i18next from "i18next"; import { closeSnackbar, enqueueSnackbar } from "notistack"; import streamSaver from "streamsaver"; -import { getFileEntityUrl } from "../../api/api.ts"; -import { FileResponse, FileType, Metadata } from "../../api/explorer.ts"; +import { getFileEntityUrl, getFileList } from "../../api/api.ts"; +import { FileResponse, FileType, ListResponse, Metadata } from "../../api/explorer.ts"; import { GroupPermission } from "../../api/user.ts"; import { DefaultCloseAction, @@ -12,7 +12,7 @@ import { BatchDownloadCompleteAction, } from "../../component/Common/Snackbar/snackbar.tsx"; import SessionManager from "../../session"; -import { getFileLinkedUri } from "../../util"; +import { getFileLinkedUri, sizeToString } from "../../util"; import Boolset from "../../util/boolset.ts"; import { formatLocalTime } from "../../util/datetime.ts"; import { @@ -25,6 +25,7 @@ import { closeContextMenu } from "../fileManagerSlice.ts"; import { DialogSelectOption, setBatchDownloadLog, setBatchDownloadProgress } from "../globalStateSlice.ts"; import { AppThunk } from "../store.ts"; import { promiseId, selectOption } from "./dialog.ts"; +import { MinPageSize } from "../../component/FileManager/TopBar/ViewOptionPopover.tsx"; import { longRunningTaskWithSnackbar, refreshSingleFileSymbolicLinks, walk, walkAll } from "./file.ts"; enum MultipleDownloadOption { @@ -51,6 +52,54 @@ export function downloadFiles(index: number, files: FileResponse[]): AppThunk { }; } +export function downloadAllFiles(index: number): AppThunk { + return async (dispatch, getState) => { + const fm = getState().fileManager[index]; + const uri = fm.pure_path; + if (!uri) { + return; + } + + // Fetch all files in the current folder, bypassing pagination + const allFiles: FileResponse[] = []; + let nextToken: string | undefined = undefined; + let page: number | undefined = undefined; + while (true) { + const res: ListResponse = await dispatch( + getFileList({ + uri, + next_page_token: nextToken, + page, + page_size: 1000, + }), + ); + allFiles.push(...res.files); + if (res.pagination.total_items) { + page = (page ?? 0) + 1; + } else if (res.pagination.next_token) { + nextToken = res.pagination.next_token; + } + + const pageSize = res.pagination?.page_size; + const totalPages = Math.ceil( + (res.pagination.total_items ?? 1) / (pageSize && pageSize > 0 ? pageSize : MinPageSize), + ); + const usePagination = totalPages > 1; + const loadMore = nextToken || (usePagination && (page ?? 0) < totalPages); + + if (!loadMore) { + break; + } + } + + if (allFiles.length === 0) { + return; + } + + await dispatch(downloadMultipleFiles(allFiles)); + }; +} + export function downloadMultipleFiles(files: FileResponse[]): AppThunk { return async (dispatch, _getState) => { // Prepare download options @@ -68,11 +117,21 @@ export function downloadMultipleFiles(files: FileResponse[]): AppThunk { options.push(MultipleDownloadOption.Backend); } + const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0); + let finalOption = options[0]; if (options.length > 1) { try { + const fileCount = files.filter((f) => f.type === FileType.file).length; + const folderCount = files.filter((f) => f.type === FileType.folder).length; + const subtitle = + (fileCount > 0 ? fileCount + " " + i18next.t("fileManager.filesCount") : "") + + (fileCount > 0 && folderCount > 0 ? ", " : "") + + (folderCount > 0 ? folderCount + " " + i18next.t("fileManager.foldersCount") : "") + + " ยท " + + sizeToString(totalSize); finalOption = (await dispatch( - selectOption(getDownloadSelectOption(options), "fileManager.selectArchiveMethod"), + selectOption(getDownloadSelectOption(options, totalSize), "fileManager.selectArchiveMethod", subtitle), )) as MultipleDownloadOption; } catch (e) { // User cancel selection @@ -554,7 +613,10 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string) }; } -const getDownloadSelectOption = (options: MultipleDownloadOption[]): DialogSelectOption[] => { +const BROWSER_ARCHIVE_SIZE_LIMIT = 4 * 1024 * 1024 * 1024; // 4 GB + +const getDownloadSelectOption = (options: MultipleDownloadOption[], totalSize: number): DialogSelectOption[] => { + const exceedsLimit = totalSize > BROWSER_ARCHIVE_SIZE_LIMIT; return options.map((option): DialogSelectOption => { switch (option) { case MultipleDownloadOption.Backend: @@ -573,7 +635,12 @@ const getDownloadSelectOption = (options: MultipleDownloadOption[]): DialogSelec return { value: MultipleDownloadOption.StreamSaver, name: i18next.t("fileManager.browserBatchDownload"), - description: i18next.t("fileManager.browserBatchDownloadDescription"), + description: exceedsLimit + ? i18next.t("fileManager.browserBatchDownloadSizeExceededDescription", { + size: sizeToString(totalSize), + }) + : i18next.t("fileManager.browserBatchDownloadDescription"), + disabled: exceedsLimit, }; } });