Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions public/locales/en-US/application.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@
"open": "Open",
"openParentFolder": "Go to parent folder",
"download": "Download",
"downloadAll": "Download all",

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.

Since you're using Claude Code, please update all other locales. It should be easy with the help of Claude.

"downloadFolder": "Download folder",
"batchDownload": "Download in batch",
"share": "Share",
"rename": "Rename",
Expand Down Expand Up @@ -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.",
Comment thread
OnAirGame marked this conversation as resolved.
Outdated
"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
2 changes: 1 addition & 1 deletion src/component/Dialogs/SelectOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,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
32 changes: 29 additions & 3 deletions src/component/FileManager/TopBar/NavHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Stack
direction={"row"}
Expand Down Expand Up @@ -36,6 +49,19 @@ const NavHeader = () => {
<RadiusFrame>
<TopActions />
</RadiusFrame>
{showDownloadFolder && (

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.

For folder(s) we already have those btn, so I think there is no need to add one more btn here.

Image

@OnAirGame OnAirGame Apr 1, 2026

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.

This existing download works for selecting a folder from the parent, but if I'm inside a folder or shared folder there is no way to download all files. "Select all" only selects the current page due to pagination.

For a client, or a recipient of a link, I think it's important to have a one click 'download all' or 'download folder' button, especially in cases when there are lots of files being sent (for example a photo gallery from a job with 500 photos in it etc).

Similar services like frame.io and Dropbox have this feature for share pages specifically as a client or customer receiving a link they need an option to be able to download all the contents of the folder with one click.

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.

Thats fine, but on mobile broswer (possibly, i just test on device simulation mode) is will looks like this

截屏2026-04-01 13 06 20

Maybe we should consider to re-locate that?

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.

Have made some tweaks to the UI/UX for this section, let me know if you like any of this flow

Mobile layout: The "Download folder" button now shows icon-only on mobile and text + icon on desktop, with a small animation.

No duplicate buttons: The download folder button automatically hides when files are selected, since the navbar's FileSelectedActions already provides a download button for selections (on mobile)

File summary in archive method dialog: The "Select archive method" dialog now shows a subtitle with the file/folder count and total size (e.g. "2 files · 4.5 GB"), giving users context about what they're downloading before they pick a method. This is a generic enhancement to SelectOption - added optional subtitle support that any dialog can use.

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.

It's still too narrow on mobile. Sugeest moving to secondary context menu on mobile:
image

<RadiusFrame>
<ActionButtonGroup variant="outlined">
<ActionButton
startIcon={<Download />}
onClick={() => dispatch(downloadAllFiles(fmIndex))}
sx={{ color: "primary.main" }}
>
{t("application:fileManager.downloadFolder")}
</ActionButton>
</ActionButtonGroup>
</RadiusFrame>
)}
</Stack>
);
};
Expand Down
1 change: 1 addition & 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
72 changes: 66 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,13 @@ export function downloadMultipleFiles(files: FileResponse[]): AppThunk {
options.push(MultipleDownloadOption.Backend);
}

const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);

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.

This calculation of total size is not accurate: Folder size is always 0. Counting folder size requires additional API calls for folder summary API, and it could be very expensive to calculate large folders.

Suggesting removing this feature.


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
Expand Down Expand Up @@ -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:
Expand All @@ -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",
Comment thread
OnAirGame marked this conversation as resolved.
Outdated
})
: i18next.t("fileManager.browserBatchDownloadDescription"),
disabled: exceedsLimit,
};
}
});
Expand Down