From c59d66f59a1c576259a5f716779ea0de489bd3dc Mon Sep 17 00:00:00 2001 From: ColorTheoryGame Date: Tue, 31 Mar 2026 22:29:58 +1300 Subject: [PATCH 1/5] feat(filemanager): add download all button and disable browser archive for large files Add a "Download all" option that fetches all files in the current folder (bypassing pagination) and triggers the existing download flow. On share pages, a visible "Download folder" button appears in the toolbar for easy access by share recipients. On the main file manager, the option is available in the more actions menu (hidden in trash view). Also disable the browser-side archiving option in the archive method picker when total selected file size exceeds 4 GB, showing the actual size and explaining the limit. - Add downloadAllFiles thunk with full pagination support - Add "Download folder" button to share page toolbar (NavHeader) - Add "Download all" to more actions menu (MoreActionMenu) - Add disabled state support to SelectOption dialog - Add i18n strings for new UI text Co-Authored-By: Claude Opus 4.6 (1M context) --- public/locales/en-US/application.json | 3 + src/component/Dialogs/SelectOption.tsx | 2 +- .../FileManager/TopBar/MoreActionMenu.tsx | 18 +++++ .../FileManager/TopBar/NavHeader.tsx | 32 ++++++++- src/redux/globalStateSlice.ts | 1 + src/redux/thunks/download.ts | 72 +++++++++++++++++-- 6 files changed, 118 insertions(+), 10 deletions(-) diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index b1e8652a0..8635cc7ff 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -315,6 +315,8 @@ "open": "Open", "openParentFolder": "Go to parent folder", "download": "Download", + "downloadAll": "Download all", + "downloadFolder": "Download folder", "batchDownload": "Download in batch", "share": "Share", "rename": "Rename", @@ -410,6 +412,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.", + "browserBatchDownloadSizeExceeded": "Total size {{size}} exceeds the 4 GB browser archive 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..bff263e9d 100644 --- a/src/component/Dialogs/SelectOption.tsx +++ b/src/component/Dialogs/SelectOption.tsx @@ -44,7 +44,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..6924f729e 100644 --- a/src/component/FileManager/TopBar/NavHeader.tsx +++ b/src/component/FileManager/TopBar/NavHeader.tsx @@ -1,13 +1,26 @@ import { Stack, useMediaQuery, useTheme } from "@mui/material"; -import Breadcrumb from "./Breadcrumb.tsx"; -import TopActions from "./TopActions.tsx"; +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 showDownloadFolder = fs == Filesystem.share && !isSingleFileView; return ( { + {showDownloadFolder && ( + + + } + onClick={() => dispatch(downloadAllFiles(fmIndex))} + sx={{ color: "primary.main" }} + > + {t("application:fileManager.downloadFolder")} + + + + )} ); }; diff --git a/src/redux/globalStateSlice.ts b/src/redux/globalStateSlice.ts index 6d8a87477..b9cde2ee4 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 { diff --git a/src/redux/thunks/download.ts b/src/redux/thunks/download.ts index 64a816d1d..a448e8cbc 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,13 @@ 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 { finalOption = (await dispatch( - selectOption(getDownloadSelectOption(options), "fileManager.selectArchiveMethod"), + selectOption(getDownloadSelectOption(options, totalSize), "fileManager.selectArchiveMethod"), )) as MultipleDownloadOption; } catch (e) { // User cancel selection @@ -554,7 +605,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 +627,13 @@ const getDownloadSelectOption = (options: MultipleDownloadOption[]): DialogSelec return { value: MultipleDownloadOption.StreamSaver, name: i18next.t("fileManager.browserBatchDownload"), - description: i18next.t("fileManager.browserBatchDownloadDescription"), + description: exceedsLimit + ? i18next.t("fileManager.browserBatchDownloadSizeExceeded", { + size: sizeToString(totalSize), + defaultValue: "Total size {{size}} exceeds the 4 GB browser archive limit", + }) + : i18next.t("fileManager.browserBatchDownloadDescription"), + disabled: exceedsLimit, }; } }); From 070624fd8aa46132b1871b44cb37e20344ef3aca Mon Sep 17 00:00:00 2001 From: Matthew Date: Wed, 1 Apr 2026 16:16:30 +1300 Subject: [PATCH 2/5] Update public/locales/en-US/application.json Co-authored-by: Darren Yu --- public/locales/en-US/application.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index 8635cc7ff..fd38f1b2a 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -412,7 +412,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.", - "browserBatchDownloadSizeExceeded": "Total size {{size}} exceeds the 4 GB browser archive limit.", + "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", From 50b55ce0af6ac68c5694699283df86ef5319dcdd Mon Sep 17 00:00:00 2001 From: ColorTheoryGame Date: Wed, 1 Apr 2026 16:19:44 +1300 Subject: [PATCH 3/5] fix: update i18n key to match renamed locale string Co-Authored-By: Claude Opus 4.6 (1M context) --- src/redux/thunks/download.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/redux/thunks/download.ts b/src/redux/thunks/download.ts index a448e8cbc..b248a6f0a 100644 --- a/src/redux/thunks/download.ts +++ b/src/redux/thunks/download.ts @@ -628,9 +628,8 @@ const getDownloadSelectOption = (options: MultipleDownloadOption[], totalSize: n value: MultipleDownloadOption.StreamSaver, name: i18next.t("fileManager.browserBatchDownload"), description: exceedsLimit - ? i18next.t("fileManager.browserBatchDownloadSizeExceeded", { + ? i18next.t("fileManager.browserBatchDownloadSizeExceededDescription", { size: sizeToString(totalSize), - defaultValue: "Total size {{size}} exceeds the 4 GB browser archive limit", }) : i18next.t("fileManager.browserBatchDownloadDescription"), disabled: exceedsLimit, From 2d906623397a5a79ab7001748fb293b62f8de0d4 Mon Sep 17 00:00:00 2001 From: ColorTheoryGame Date: Thu, 2 Apr 2026 10:55:11 +1300 Subject: [PATCH 4/5] fix: show icon-only download button on mobile Co-Authored-By: Claude Opus 4.6 (1M context) --- src/component/FileManager/TopBar/NavHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/component/FileManager/TopBar/NavHeader.tsx b/src/component/FileManager/TopBar/NavHeader.tsx index 6924f729e..5dbaf3e51 100644 --- a/src/component/FileManager/TopBar/NavHeader.tsx +++ b/src/component/FileManager/TopBar/NavHeader.tsx @@ -53,11 +53,11 @@ const NavHeader = () => { } + startIcon={!isMobile && } onClick={() => dispatch(downloadAllFiles(fmIndex))} sx={{ color: "primary.main" }} > - {t("application:fileManager.downloadFolder")} + {isMobile ? : t("application:fileManager.downloadFolder")} From 1ed8b5f250d60faae91249b4f7299a6f1e3f91a4 Mon Sep 17 00:00:00 2001 From: ColorTheoryGame Date: Thu, 2 Apr 2026 11:34:47 +1300 Subject: [PATCH 5/5] fix: improve download button UX and add file summary to archive method dialog - Hide download folder button when files are selected (avoids duplicate with navbar download button) - Show icon-only on mobile, text + icon on desktop - Smooth horizontal collapse animation on button show/hide - Add file/folder count and total size subtitle to archive method dialog - Add generic subtitle support to SelectOption dialog - Remove unnecessary fetchFiles wrapper - Add i18n keys for file/folder counts Co-Authored-By: Claude Opus 4.6 (1M context) --- public/locales/en-US/application.json | 2 ++ src/component/Dialogs/SelectOption.tsx | 14 ++++++++++++-- src/component/FileManager/TopBar/NavHeader.tsx | 14 ++++++++++---- src/redux/globalStateSlice.ts | 3 +++ src/redux/thunks/dialog.ts | 7 ++++++- src/redux/thunks/download.ts | 10 +++++++++- 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index fd38f1b2a..569d20e66 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -317,6 +317,8 @@ "download": "Download", "downloadAll": "Download all", "downloadFolder": "Download folder", + "filesCount": "files", + "foldersCount": "folders", "batchDownload": "Download in batch", "share": "Share", "rename": "Rename", diff --git a/src/component/Dialogs/SelectOption.tsx b/src/component/Dialogs/SelectOption.tsx index bff263e9d..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, diff --git a/src/component/FileManager/TopBar/NavHeader.tsx b/src/component/FileManager/TopBar/NavHeader.tsx index 5dbaf3e51..f3bb33c01 100644 --- a/src/component/FileManager/TopBar/NavHeader.tsx +++ b/src/component/FileManager/TopBar/NavHeader.tsx @@ -1,4 +1,4 @@ -import { Stack, useMediaQuery, useTheme } from "@mui/material"; +import { Collapse, Stack, useMediaQuery, useTheme } from "@mui/material"; import { useContext } from "react"; import { useTranslation } from "react-i18next"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; @@ -20,6 +20,7 @@ const NavHeader = () => { 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 ( { - {showDownloadFolder && ( + } onClick={() => dispatch(downloadAllFiles(fmIndex))} - sx={{ color: "primary.main" }} + sx={{ color: "primary.main", whiteSpace: "nowrap" }} > {isMobile ? : t("application:fileManager.downloadFolder")} - )} + ); }; diff --git a/src/redux/globalStateSlice.ts b/src/redux/globalStateSlice.ts index b9cde2ee4..9fddf8b2a 100644 --- a/src/redux/globalStateSlice.ts +++ b/src/redux/globalStateSlice.ts @@ -179,6 +179,7 @@ export interface GlobalStateSlice { selectOptionDialogOptions?: DialogSelectOption[]; selectOptionPromiseId?: string; selectOptionTitle?: string; + selectOptionSubtitle?: string; // Batch download log dialog batchDownloadLogDialogOpen?: boolean; @@ -515,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 b248a6f0a..9f9cfede3 100644 --- a/src/redux/thunks/download.ts +++ b/src/redux/thunks/download.ts @@ -122,8 +122,16 @@ export function downloadMultipleFiles(files: FileResponse[]): AppThunk { 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, totalSize), "fileManager.selectArchiveMethod"), + selectOption(getDownloadSelectOption(options, totalSize), "fileManager.selectArchiveMethod", subtitle), )) as MultipleDownloadOption; } catch (e) { // User cancel selection