diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 83247b2e..d46d432a 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -470,7 +470,8 @@ "ftsStatus": "Full-text index", "ftsIndexed": "Indexed", "ftsNotIndexed": "Not indexed", - "lineEnding": "Line ending" + "lineEnding": "Line ending", + "downloadAll": "Download all" }, "modals": { "includePasswordInShareLink": "Include password in share link", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 5baaa67a..3b2db71f 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -470,7 +470,8 @@ "ftsStatus": "全文索引", "ftsIndexed": "已索引", "ftsNotIndexed": "未索引", - "lineEnding": "换行符" + "lineEnding": "换行符", + "downloadAll": "全部下载" }, "modals": { "includePasswordInShareLink": "在链接中包含密码", diff --git a/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx b/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx index 11d05868..2a4fe23f 100644 --- a/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx +++ b/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx @@ -4,15 +4,19 @@ import { useCallback, useContext } from "react"; import { useTranslation } from "react-i18next"; import { clearSelected } from "../../../redux/fileManagerSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { NavigatorCapability } from "../../../api/explorer.ts"; +import { downloadAll } from "../../../redux/thunks/download.ts"; import { createShareShortcut, isMacbook } from "../../../redux/thunks/file.ts"; import { inverseSelection, pinCurrentView, refreshFileList, selectAll } from "../../../redux/thunks/filemanager.ts"; import SessionManager from "../../../session"; +import Boolset from "../../../util/boolset.ts"; import { Filesystem } from "../../../util/uri.ts"; import { KeyIndicator } from "../../Frame/NavBar/SearchBar.tsx"; import ArrowSync from "../../Icons/ArrowSync.tsx"; import Border from "../../Icons/Border.tsx"; import BorderAll from "../../Icons/BorderAll.tsx"; import BorderInside from "../../Icons/BorderInside.tsx"; +import CloudDownloadOutlined from "../../Icons/CloudDownloadOutlined.tsx"; import FolderLink from "../../Icons/FolderLink.tsx"; import PinOutlined from "../../Icons/PinOutlined.tsx"; import { DenseDivider, SquareMenu, SquareMenuItem } from "../ContextMenu/ContextMenu.tsx"; @@ -22,6 +26,8 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => { const { t } = useTranslation(); const fmIndex = useContext(FmIndexContext); const fs = useAppSelector((state) => state.fileManager[fmIndex].current_fs); + const parentCapability = useAppSelector((state) => state.fileManager[fmIndex].list?.parent?.capability); + const canDownloadAll = new Boolset(parentCapability).enabled(NavigatorCapability.download_file); const dispatch = useAppDispatch(); const isLogin = !!SessionManager.currentLoginOrNull(); const theme = useTheme(); @@ -62,6 +68,11 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => { dispatch(refreshFileList(fmIndex)); }, [dispatch, onClose, fmIndex]); + const onDownloadAllClicked = useCallback(() => { + onClose && onClose({}, "escapeKeyDown"); + dispatch(downloadAll(fmIndex)); + }, [dispatch, onClose, fmIndex]); + return ( { {t("application:fileManager.saveShortcut")} )} + {fs == Filesystem.share && canDownloadAll && ( + + + + + {t("application:fileManager.downloadAll")} + + )} {isLogin && } diff --git a/frontend/src/redux/thunks/download.ts b/frontend/src/redux/thunks/download.ts index 64a816d1..6b97f41e 100644 --- a/frontend/src/redux/thunks/download.ts +++ b/frontend/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, @@ -51,6 +51,32 @@ export function downloadFiles(index: number, files: FileResponse[]): AppThunk { }; } +// downloadAll fetches every page of the current directory listing and +// downloads the whole set — the visible list may be only the first page. +export function downloadAll(index: number): AppThunk { + return async (dispatch, getState) => { + const path = getState().fileManager[index]?.path; + if (!path) { + return; + } + + const files: FileResponse[] = []; + let token: string | undefined = undefined; + do { + const res: ListResponse = await dispatch( + getFileList({ uri: path, next_page_token: token, page_size: 1000 }), + ); + files.push(...(res.files ?? [])); + token = res.pagination?.next_token; + } while (token); + + if (files.length == 0) { + return; + } + await dispatch(downloadFiles(index, files)); + }; +} + export function downloadMultipleFiles(files: FileResponse[]): AppThunk { return async (dispatch, _getState) => { // Prepare download options