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
5 changes: 5 additions & 0 deletions public/locales/en-US/application.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 13 additions & 3 deletions src/component/Dialogs/SelectOption.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);

Expand All @@ -34,7 +35,16 @@ const SelectOption = () => {

return (
<DraggableDialog
title={t(title ?? "")}
title={
<>
{t(title ?? "")}
{subtitle && (
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: "normal" }}>
{subtitle}
</Typography>
)}
</>
}
dialogProps={{
open: open ?? false,
onClose: onClose,
Expand All @@ -44,7 +54,7 @@ const SelectOption = () => {
<DialogContent>
<List component="nav">
{options?.map((o) => (
<ListItemButton key={o.value} onClick={() => onAccept(o.value)}>
<ListItemButton key={o.value} disabled={o.disabled} onClick={() => onAccept(o.value)}>
<ListItemText
primary={o.name}
secondary={o.description}
Expand Down
18 changes: 18 additions & 0 deletions src/component/FileManager/TopBar/MoreActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useContext } from "react";
import { useTranslation } from "react-i18next";
import { clearSelected } from "../../../redux/fileManagerSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { downloadAllFiles } 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";
Expand All @@ -13,6 +14,7 @@ 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 Download from "../../Icons/Download.tsx";
import FolderLink from "../../Icons/FolderLink.tsx";
import PinOutlined from "../../Icons/PinOutlined.tsx";
import { DenseDivider, SquareMenu, SquareMenuItem } from "../ContextMenu/ContextMenu.tsx";
Expand Down Expand Up @@ -57,6 +59,11 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => {
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));
Expand Down Expand Up @@ -126,6 +133,17 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => {
</ListItemIcon>
<ListItemText>{t("application:fileManager.invertSelection")}</ListItemText>
</SquareMenuItem>
{fs != Filesystem.trash && fs != Filesystem.share && (
<>
<DenseDivider />
<SquareMenuItem onClick={onDownloadAllClicked}>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.downloadAll")}</ListItemText>
</SquareMenuItem>
</>
)}
</SquareMenu>
);
};
Expand Down
40 changes: 36 additions & 4 deletions src/component/FileManager/TopBar/NavHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Stack
direction={"row"}
Expand Down Expand Up @@ -36,6 +50,24 @@ const NavHeader = () => {
<RadiusFrame>
<TopActions />
</RadiusFrame>
<Collapse
in={showDownloadFolder && !hasSelection}
orientation="horizontal"
unmountOnExit
sx={{ "& .MuiCollapse-wrapperInner": { display: "flex" } }}
>
<RadiusFrame>
<ActionButtonGroup variant="outlined">
<ActionButton
startIcon={!isMobile && <Download />}
onClick={() => dispatch(downloadAllFiles(fmIndex))}
sx={{ color: "primary.main", whiteSpace: "nowrap" }}
>
{isMobile ? <Download fontSize={"small"} /> : t("application:fileManager.downloadFolder")}
</ActionButton>
</ActionButtonGroup>
</RadiusFrame>
</Collapse>
</Stack>
);
};
Expand Down
4 changes: 4 additions & 0 deletions src/redux/globalStateSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface DialogSelectOption {
name: string;
description: string;
value: any;
disabled?: boolean;
}

export interface DesktopCallbackState {
Expand Down Expand Up @@ -178,6 +179,7 @@ export interface GlobalStateSlice {
selectOptionDialogOptions?: DialogSelectOption[];
selectOptionPromiseId?: string;
selectOptionTitle?: string;
selectOptionSubtitle?: string;

// Batch download log dialog
batchDownloadLogDialogOpen?: boolean;
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/redux/thunks/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,11 @@ export function requestCreateNew(fmIndex: number, type: string, defaultName?: st
};
}

export function selectOption(options: DialogSelectOption[], title: string): AppThunk<Promise<any> | Promise<void>> {
export function selectOption(
options: DialogSelectOption[],
title: string,
subtitle?: string,
): AppThunk<Promise<any> | Promise<void>> {
return async (dispatch) => {
const id = promiseId();
return new Promise<any>((resolve, reject) => {
Expand All @@ -260,6 +264,7 @@ export function selectOption(options: DialogSelectOption[], title: string): AppT
title,
options,
promiseId: id,
subtitle,
}),
);
});
Expand Down
79 changes: 73 additions & 6 deletions src/redux/thunks/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -51,6 +52,54 @@ export function downloadFiles(index: number, files: FileResponse[]): AppThunk {
};
}

export function downloadAllFiles(index: number): AppThunk {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can make a small change in backend to allow calculate total size on root file

{"code":403,"msg":"Not supported action","error":"cannot operate root file","correlation_id":""}

And then we can calculate the file size on server side and re-use the browserBatchDownload.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, cleaner approach. want me to wait for the backend change, or is the current frontend pagination approach OK for now?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, I have raise a new pull request to backend, you may remove the pagination approach after it is merged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can update to use this once merged

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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
};
}
});
Expand Down