From f4e5e211cc59ed94feee3b072fc0a35b2fd5b95f Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 23 Jul 2026 02:31:34 +0330 Subject: [PATCH] feat(downloads): add details controls and aggregate summaries --- src/components/DownloadTable.tsx | 39 +++++++ src/components/PropertiesModal.tsx | 54 ++++++++++ src/i18n/catalogs/en.ts | 9 ++ src/i18n/catalogs/fa.ts | 9 ++ src/i18n/catalogs/he.ts | 9 ++ src/i18n/catalogs/ru.ts | 9 ++ src/i18n/catalogs/uk.ts | 9 ++ src/i18n/catalogs/zh-CN.ts | 9 ++ src/index.css | 38 ++++++- src/utils/downloadActions.test.ts | 13 +++ src/utils/downloadActions.ts | 8 ++ src/utils/downloadSummary.test.ts | 159 +++++++++++++++++++++++++++++ src/utils/downloadSummary.ts | 116 +++++++++++++++++++++ 13 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 src/utils/downloadSummary.test.ts create mode 100644 src/utils/downloadSummary.ts diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 0944f75..8332927 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore'; +import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; import { SidebarFilter } from './Sidebar'; @@ -21,6 +22,8 @@ import { canStartDownload } from '../utils/downloadActions'; import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads'; +import { formatDownloadBytes } from '../utils/downloadProgress'; +import { summarizeDownloads } from '../utils/downloadSummary'; import { readClipboardDownloadUrls } from '../utils/clipboard'; import { useTranslation } from 'react-i18next'; import { @@ -100,6 +103,7 @@ interface ColumnLayoutBounds { export const DownloadTable: React.FC = ({ filter }) => { const { t } = useTranslation(); const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore(); + const progressMap = useDownloadProgressStore(state => state.progressMap); const { addToast } = useToast(); const isMac = navigator.userAgent.includes('Mac'); const [isReadingClipboard, setIsReadingClipboard] = useState(false); @@ -718,6 +722,26 @@ export const DownloadTable: React.FC = ({ filter }) => { : sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig), [filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]); + const selectedDownloads = useMemo( + () => filteredDownloads.filter(download => selectedIds.has(download.id)), + [filteredDownloads, selectedIds] + ); + const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads; + const downloadSummary = useMemo( + () => summarizeDownloads(summaryDownloads, progressMap), + [summaryDownloads, progressMap] + ); + const summaryScopeLabel = selectedDownloads.length > 0 + ? t($ => $.downloadTable.summary.selected, { count: downloadSummary.itemCount }) + : t($ => $.downloadTable.summary.items, { count: downloadSummary.itemCount }); + const formatSummaryBytes = (value: number | null, isEstimated = false): string => { + if (value === null) return t($ => $.downloadTable.summary.unknown); + const formatted = formatDownloadBytes(value); + return isEstimated + ? t($ => $.downloadTable.summary.estimated, { value: formatted }) + : formatted; + }; + // Each row used to derive this by filtering and sorting the complete store // independently. That made a 1000-entry playlist perform O(n^2 log n) work // on every download update. Compute the same queue membership once and pass @@ -1040,6 +1064,21 @@ export const DownloadTable: React.FC = ({ filter }) => { {getFilterTitle()} {sortedDownloads.length} +
+ {summaryScopeLabel} + + {t($ => $.downloadTable.summary.downloaded)} + {formatSummaryBytes(downloadSummary.downloadedBytes)} + + + {t($ => $.downloadTable.summary.remaining)} + {formatSummaryBytes(downloadSummary.remainingBytes, downloadSummary.remainingIsEstimated)} + + + {t($ => $.downloadTable.summary.active)} + {downloadSummary.activeCount} + +
diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 4cf50e9..034d03d 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, import { open } from '@tauri-apps/plugin-dialog'; import { resolveCategoryDestination } from '../utils/downloadLocations'; import { + getPauseResumeAction, isIdentityLocked as getIdentityLocked, isTransferLocked as getTransferLocked } from '../utils/downloadActions'; @@ -79,6 +80,7 @@ export const PropertiesModal = () => { const [mirrors, setMirrors] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + const [isPauseResumePending, setIsPauseResumePending] = useState(false); useEffect(() => { if (selectedPropertiesDownloadId) { @@ -205,6 +207,38 @@ export const PropertiesModal = () => { } }; + const handlePauseResume = async () => { + const currentItem = useDownloadStore.getState().downloads.find(download => download.id === item.id); + const action = currentItem ? getPauseResumeAction(currentItem.status) : null; + if (!currentItem || !action || isPauseResumePending) return; + + if (action === 'pause' && currentItem.resumable === false) { + const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne)); + if (!confirmPause) return; + } + + setErrorMessage(''); + setIsPauseResumePending(true); + try { + if (action === 'pause') { + await useDownloadStore.getState().pauseDownload(currentItem.id); + } else { + const resumed = await useDownloadStore.getState().resumeDownload(currentItem.id); + if (!resumed) { + throw new Error(t($ => $.downloadTable.backendRejectedStart)); + } + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + const message = action === 'pause' + ? t($ => $.downloadTable.pauseFailed) + : t($ => $.downloadTable.resumeFailed, { fileName: currentItem.fileName }); + setErrorMessage(t($ => $.downloadTable.interactionError, { message, detail })); + } finally { + setIsPauseResumePending(false); + } + }; + const identityLocked = getIdentityLocked(item.status); const transferLocked = getTransferLocked(item.status); const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections); @@ -262,6 +296,11 @@ export const PropertiesModal = () => { return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value; })(); const statusLabel = t($ => $.downloads.status[item.status]); + const pauseResumeAction = getPauseResumeAction(item.status); + const pauseResumeLabel = pauseResumeAction === 'pause' + ? t($ => $.downloadTable.pause) + : t($ => $.downloadTable.resume); + const PauseResumeIcon = pauseResumeAction === 'pause' ? Pause : Play; const sizeDescription = sizeDisplay.totalIsEstimate ? t($ => $.downloads.size.downloadedOfApproximate, { downloaded: sizeDisplay.downloaded ?? '', @@ -513,12 +552,27 @@ export const PropertiesModal = () => {
+ {pauseResumeAction && ( + + )}