feat(downloads): add details controls and aggregate summaries

This commit is contained in:
NimBold
2026-07-23 02:31:34 +03:30
parent 9d737598c6
commit f4e5e211cc
13 changed files with 480 additions and 1 deletions
+39
View File
@@ -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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ filter }) => {
{getFilterTitle()}
<span className="downloads-count">{sortedDownloads.length}</span>
</div>
<div className="downloads-summary">
<span className="downloads-summary-scope">{summaryScopeLabel}</span>
<span className="downloads-summary-metric">
<span className="downloads-summary-label">{t($ => $.downloadTable.summary.downloaded)}</span>
<span className="downloads-summary-value">{formatSummaryBytes(downloadSummary.downloadedBytes)}</span>
</span>
<span className="downloads-summary-metric">
<span className="downloads-summary-label">{t($ => $.downloadTable.summary.remaining)}</span>
<span className="downloads-summary-value">{formatSummaryBytes(downloadSummary.remainingBytes, downloadSummary.remainingIsEstimated)}</span>
</span>
<span className="downloads-summary-metric">
<span className="downloads-summary-label">{t($ => $.downloadTable.summary.active)}</span>
<span className="downloads-summary-value">{downloadSummary.activeCount}</span>
</span>
</div>
</div>
<div className="downloads-table flex-1 flex flex-col">
+54
View File
@@ -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 = () => {
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button px-4 text-xs"
>
{t($ => $.properties.cancel)}
</button>
{pauseResumeAction && (
<button
type="button"
onClick={() => void handlePauseResume()}
disabled={isPauseResumePending}
aria-label={pauseResumeLabel}
title={pauseResumeLabel}
className={`app-button px-4 text-xs ${isPauseResumePending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<PauseResumeIcon size={14} fill="currentColor" />
{pauseResumeLabel}
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={transferLocked}
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}