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' : ''}`}
+9
View File
@@ -353,6 +353,15 @@ const common = {
addDownload: 'Add Download',
resumeAll: 'Resume All',
pauseAll: 'Pause All',
summary: {
items: '{{count}} items',
selected: '{{count}} selected',
downloaded: 'Downloaded',
remaining: 'Remaining',
active: 'Active',
unknown: 'Unknown',
estimated: 'Estimated {{value}}',
},
queueEmpty: 'Queue is empty',
noCompletedDownloads: 'No Completed Downloads',
noDownloads: 'No Downloads',
+9
View File
@@ -353,6 +353,15 @@ const fa = {
addDownload: 'افزودن دانلود',
resumeAll: 'ادامه همه',
pauseAll: 'توقف همه',
summary: {
items: '{{count}} مورد',
selected: '{{count}} انتخاب‌شده',
downloaded: 'دانلودشده',
remaining: 'باقی‌مانده',
active: 'فعال',
unknown: 'نامشخص',
estimated: '{{value}} (تخمینی)',
},
queueEmpty: 'صف خالی است',
noCompletedDownloads: 'هیچ دانلود تکمیل‌شده‌ای وجود ندارد',
noDownloads: 'هیچ دانلودی وجود ندارد',
+9
View File
@@ -353,6 +353,15 @@ const he = {
addDownload: 'הוספת הורדה',
resumeAll: 'חידוש הכל',
pauseAll: 'השהיית הכל',
summary: {
items: '{{count}} פריטים',
selected: '{{count}} נבחרו',
downloaded: 'הורדו',
remaining: 'נותרו',
active: 'פעילות',
unknown: 'לא ידוע',
estimated: '{{value}} (הערכה)',
},
queueEmpty: 'התור ריק',
noCompletedDownloads: 'אין הורדות שהושלמו',
noDownloads: 'אין הורדות',
+9
View File
@@ -353,6 +353,15 @@ const ru = {
addDownload: 'Добавить загрузку',
resumeAll: 'Возобновить все',
pauseAll: 'Приостановить все',
summary: {
items: '{{count}} элементов',
selected: 'Выбрано: {{count}}',
downloaded: 'Загружено',
remaining: 'Осталось',
active: 'Активные',
unknown: 'Неизвестно',
estimated: 'Около {{value}}',
},
queueEmpty: 'Очередь пуста',
noCompletedDownloads: 'Нет завершённых загрузок',
noDownloads: 'Нет загрузок',
+9
View File
@@ -353,6 +353,15 @@ const uk = {
addDownload: 'Додати завантаження',
resumeAll: 'Відновити всі',
pauseAll: 'Призупинити всі',
summary: {
items: '{{count}} елементів',
selected: 'Вибрано: {{count}}',
downloaded: 'Завантажено',
remaining: 'Залишилось',
active: 'Активні',
unknown: 'Невідомо',
estimated: 'Приблизно {{value}}',
},
queueEmpty: 'Черга порожня',
noCompletedDownloads: 'Немає завершених завантажень',
noDownloads: 'Немає завантажень',
+9
View File
@@ -353,6 +353,15 @@ const zhCN = {
addDownload: '添加下载',
resumeAll: '全部恢复',
pauseAll: '全部暂停',
summary: {
items: '{{count}} 个项目',
selected: '已选择 {{count}}',
downloaded: '已下载',
remaining: '剩余',
active: '进行中',
unknown: '未知',
estimated: '估计 {{value}}',
},
queueEmpty: '队列为空',
noCompletedDownloads: '没有已完成的下载',
noDownloads: '没有下载',
+37 -1
View File
@@ -1805,9 +1805,11 @@ html[data-list-density="relaxed"] {
}
.downloads-content-header {
height: 58px;
min-height: 58px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
padding: 0 16px;
background: hsl(var(--main-bg));
}
@@ -1837,6 +1839,40 @@ html[data-list-density="relaxed"] {
background: hsl(var(--item-hover));
}
.downloads-summary {
min-width: 0;
flex: 1 1 360px;
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 4px 12px;
color: hsl(var(--text-secondary));
font-size: 11px;
line-height: 1.25;
}
.downloads-summary-scope {
color: hsl(var(--text-primary));
font-weight: 650;
}
.downloads-summary-metric {
display: inline-flex;
align-items: baseline;
gap: 4px;
white-space: nowrap;
}
.downloads-summary-label {
color: hsl(var(--text-muted));
}
.downloads-summary-value {
color: hsl(var(--text-secondary));
font-variant-numeric: tabular-nums;
}
.downloads-table {
flex: 1;
min-height: 0;
+13
View File
@@ -3,6 +3,7 @@ import {
canPauseDownload,
canRedownload,
canStartDownload,
getPauseResumeAction,
isIdentityLocked,
isTransferLocked,
startActionLabel,
@@ -27,6 +28,18 @@ describe('download action policy', () => {
expect(canRedownload('downloading')).toBe(false);
});
it('only exposes pause or resume for the details-view toggle', () => {
expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume');
for (const status of ['ready', 'staged', 'completed', 'failed'] as const) {
expect(getPauseResumeAction(status)).toBeNull();
}
});
it('provides consistent labels and edit locks', () => {
expect(startActionLabel('ready')).toBe('Start');
expect(startActionLabel('failed')).toBe('Start');
+8
View File
@@ -26,6 +26,14 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
export const canPauseDownload = (status: DownloadStatus): boolean =>
PAUSABLE_STATUSES.has(status);
export type PauseResumeAction = 'pause' | 'resume';
export const getPauseResumeAction = (status: DownloadStatus): PauseResumeAction | null => {
if (canPauseDownload(status)) return 'pause';
if (status === 'paused') return 'resume';
return null;
};
export const canRedownload = (status: DownloadStatus): boolean =>
REDOWNLOADABLE_STATUSES.has(status);
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { summarizeDownloads } from './downloadSummary';
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
id,
url: `https://example.com/${id}`,
fileName: `${id}.bin`,
status: 'ready',
category: 'Other',
dateAdded: '2026-01-01T00:00:00.000Z',
...overrides,
});
const progress = (id: string, overrides: Partial<DownloadProgressEvent> = {}): DownloadProgressEvent => ({
id,
fraction: 0.5,
speed: '1 MiB/s',
eta: '1m',
size: null,
size_is_final: false,
...overrides,
});
describe('download summaries', () => {
it('aggregates exact bytes and transfer-active counts', () => {
expect(summarizeDownloads([
item('active', { status: 'downloading', downloadedBytes: 1024, totalBytes: 4096 }),
item('done', { status: 'completed', totalBytes: 2048 }),
])).toEqual({
itemCount: 2,
activeCount: 1,
downloadedBytes: 3072,
remainingBytes: 3072,
remainingIsEstimated: false,
});
});
it('does not present partial byte totals as complete aggregates', () => {
expect(summarizeDownloads([
item('known', { totalBytes: 100, downloadedBytes: 20 }),
item('unknown', { status: 'failed' }),
])).toMatchObject({
itemCount: 2,
downloadedBytes: null,
remainingBytes: null,
});
});
it('infers zero bytes for an unstarted paused item but preserves partial unknowns', () => {
expect(summarizeDownloads([
item('paused', { status: 'paused', totalBytes: 1000 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1000,
remainingIsEstimated: false,
});
expect(summarizeDownloads([
item('partial', { status: 'paused', fraction: 0.4, totalBytes: 1000 }),
]).downloadedBytes).toBeNull();
});
it('treats a fresh media item as having no downloaded bytes before its first progress event', () => {
expect(summarizeDownloads([
item('media', {
status: 'ready',
isMedia: true,
totalBytes: 1024,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: 1024,
remainingIsEstimated: true,
});
});
it('keeps non-final media progress on the stored estimated denominator', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
size: '~2 MB',
}),
], {
media: progress('media', {
downloaded_bytes: 512 * 1024,
total_bytes: 1024,
total_is_estimate: true,
}),
})).toEqual({
itemCount: 1,
activeCount: 1,
downloadedBytes: 512 * 1024,
remainingBytes: 2 * 1024 ** 2 - 512 * 1024,
remainingIsEstimated: true,
});
});
it('does not claim zero remaining when an estimate is already below observed bytes', () => {
expect(summarizeDownloads([
item('media', {
status: 'downloading',
isMedia: true,
downloadedBytes: 150,
totalBytes: 100,
totalIsEstimate: true,
}),
])).toMatchObject({
downloadedBytes: 150,
remainingBytes: null,
remainingIsEstimated: false,
});
});
it('uses a final media progress total when one is available', () => {
expect(summarizeDownloads([
item('media', {
status: 'processing',
isMedia: true,
totalBytes: 2 * 1024 ** 2,
totalIsEstimate: true,
}),
], {
media: progress('media', {
downloaded_bytes: 3 * 1024 ** 2,
total_bytes: 3 * 1024 ** 2,
size_is_final: true,
total_is_estimate: false,
}),
})).toMatchObject({
downloadedBytes: 3 * 1024 ** 2,
remainingBytes: 0,
remainingIsEstimated: false,
});
});
it('does not overflow aggregate byte counters', () => {
const huge = Number.MAX_VALUE;
expect(summarizeDownloads([
item('one', { totalBytes: huge, downloadedBytes: huge }),
item('two', { totalBytes: huge, downloadedBytes: huge }),
])).toMatchObject({
downloadedBytes: null,
remainingBytes: 0,
});
expect(summarizeDownloads([
item('three', { totalBytes: huge, downloadedBytes: 0 }),
item('four', { totalBytes: huge, downloadedBytes: 0 }),
])).toMatchObject({
downloadedBytes: 0,
remainingBytes: null,
});
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import { isTransferActiveStatus } from './downloads';
export interface DownloadSummary {
itemCount: number;
activeCount: number;
downloadedBytes: number | null;
remainingBytes: number | null;
remainingIsEstimated: boolean;
}
type ProgressMap = Readonly<Record<string, DownloadProgressEvent | undefined>>;
const usableBytes = (value: number | null | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'ready' ||
status === 'staged' ||
status === 'queued' ||
status === 'downloading' ||
status === 'processing' ||
status === 'retrying';
const hasPositiveProgress = (download: DownloadItem): boolean =>
typeof download.fraction === 'number' &&
Number.isFinite(download.fraction) &&
download.fraction > 0;
const canInferNoDownloadedBytes = (download: DownloadItem): boolean =>
!hasPositiveProgress(download) &&
(isFreshDownloadStatus(download.status) || download.status === 'paused');
const effectiveByteState = (
download: DownloadItem,
progress: DownloadProgressEvent | undefined
): { downloadedBytes?: number; totalBytes?: number; totalIsEstimate: boolean } => {
const usesStoredMediaTotal = download.isMedia === true && progress && !progress.size_is_final;
const storedTotalIsEstimate = download.totalIsEstimate === true ||
download.size?.trim().startsWith('~') === true;
const totalBytes = usesStoredMediaTotal
? usableBytes(download.totalBytes)
: usableBytes(progress?.total_bytes) ?? usableBytes(download.totalBytes);
const downloadedBytes =
usableBytes(progress?.downloaded_bytes) ??
usableBytes(download.downloadedBytes) ??
(download.status === 'completed' ? totalBytes : undefined) ??
(canInferNoDownloadedBytes(download) ? 0 : undefined);
const totalIsEstimate = usesStoredMediaTotal
? storedTotalIsEstimate
: (progress?.total_is_estimate ?? storedTotalIsEstimate) === true;
return { downloadedBytes, totalBytes, totalIsEstimate };
};
export const summarizeDownloads = (
downloads: readonly DownloadItem[],
progressMap: ProgressMap = {}
): DownloadSummary => {
if (downloads.length === 0) {
return {
itemCount: 0,
activeCount: 0,
downloadedBytes: null,
remainingBytes: null,
remainingIsEstimated: false,
};
}
let downloadedBytes = 0;
let remainingBytes = 0;
let downloadedKnown = true;
let remainingKnown = true;
let remainingIsEstimated = false;
let activeCount = 0;
for (const download of downloads) {
const state = effectiveByteState(download, progressMap[download.id]);
if (isTransferActiveStatus(download.status)) activeCount += 1;
if (state.downloadedBytes === undefined) {
downloadedKnown = false;
} else {
const nextDownloadedBytes = downloadedBytes + state.downloadedBytes;
if (Number.isFinite(nextDownloadedBytes)) {
downloadedBytes = nextDownloadedBytes;
} else {
downloadedKnown = false;
}
}
if (
state.totalBytes === undefined ||
state.downloadedBytes === undefined ||
state.downloadedBytes > state.totalBytes
) {
remainingKnown = false;
} else {
const nextRemainingBytes = remainingBytes + Math.max(0, state.totalBytes - state.downloadedBytes);
if (Number.isFinite(nextRemainingBytes)) {
remainingBytes = nextRemainingBytes;
remainingIsEstimated ||= state.totalIsEstimate;
} else {
remainingKnown = false;
}
}
}
return {
itemCount: downloads.length,
activeCount,
downloadedBytes: downloadedKnown ? downloadedBytes : null,
remainingBytes: remainingKnown ? remainingBytes : null,
remainingIsEstimated,
};
};