mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 20:17:46 +00:00
feat(downloads): add details controls and aggregate summaries
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||||
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { SidebarFilter } from './Sidebar';
|
import { SidebarFilter } from './Sidebar';
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
canStartDownload
|
canStartDownload
|
||||||
} from '../utils/downloadActions';
|
} from '../utils/downloadActions';
|
||||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||||
|
import { formatDownloadBytes } from '../utils/downloadProgress';
|
||||||
|
import { summarizeDownloads } from '../utils/downloadSummary';
|
||||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +103,7 @@ interface ColumnLayoutBounds {
|
|||||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||||
|
const progressMap = useDownloadProgressStore(state => state.progressMap);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const isMac = navigator.userAgent.includes('Mac');
|
const isMac = navigator.userAgent.includes('Mac');
|
||||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||||
@@ -718,6 +722,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
: sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig),
|
||||||
[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
|
// 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
|
// 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
|
// on every download update. Compute the same queue membership once and pass
|
||||||
@@ -1040,6 +1064,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
{getFilterTitle()}
|
{getFilterTitle()}
|
||||||
<span className="downloads-count">{sortedDownloads.length}</span>
|
<span className="downloads-count">{sortedDownloads.length}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="downloads-table flex-1 flex flex-col">
|
<div className="downloads-table flex-1 flex flex-col">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle,
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||||
import {
|
import {
|
||||||
|
getPauseResumeAction,
|
||||||
isIdentityLocked as getIdentityLocked,
|
isIdentityLocked as getIdentityLocked,
|
||||||
isTransferLocked as getTransferLocked
|
isTransferLocked as getTransferLocked
|
||||||
} from '../utils/downloadActions';
|
} from '../utils/downloadActions';
|
||||||
@@ -79,6 +80,7 @@ export const PropertiesModal = () => {
|
|||||||
const [mirrors, setMirrors] = useState('');
|
const [mirrors, setMirrors] = useState('');
|
||||||
|
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPropertiesDownloadId) {
|
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 identityLocked = getIdentityLocked(item.status);
|
||||||
const transferLocked = getTransferLocked(item.status);
|
const transferLocked = getTransferLocked(item.status);
|
||||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||||
@@ -262,6 +296,11 @@ export const PropertiesModal = () => {
|
|||||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||||
})();
|
})();
|
||||||
const statusLabel = t($ => $.downloads.status[item.status]);
|
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
|
const sizeDescription = sizeDisplay.totalIsEstimate
|
||||||
? t($ => $.downloads.size.downloadedOfApproximate, {
|
? t($ => $.downloads.size.downloadedOfApproximate, {
|
||||||
downloaded: sizeDisplay.downloaded ?? '',
|
downloaded: sizeDisplay.downloaded ?? '',
|
||||||
@@ -513,12 +552,27 @@ export const PropertiesModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||||
className="app-button px-4 text-xs"
|
className="app-button px-4 text-xs"
|
||||||
>
|
>
|
||||||
{t($ => $.properties.cancel)}
|
{t($ => $.properties.cancel)}
|
||||||
</button>
|
</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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={transferLocked}
|
disabled={transferLocked}
|
||||||
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
|||||||
@@ -353,6 +353,15 @@ const common = {
|
|||||||
addDownload: 'Add Download',
|
addDownload: 'Add Download',
|
||||||
resumeAll: 'Resume All',
|
resumeAll: 'Resume All',
|
||||||
pauseAll: 'Pause 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',
|
queueEmpty: 'Queue is empty',
|
||||||
noCompletedDownloads: 'No Completed Downloads',
|
noCompletedDownloads: 'No Completed Downloads',
|
||||||
noDownloads: 'No Downloads',
|
noDownloads: 'No Downloads',
|
||||||
|
|||||||
@@ -353,6 +353,15 @@ const fa = {
|
|||||||
addDownload: 'افزودن دانلود',
|
addDownload: 'افزودن دانلود',
|
||||||
resumeAll: 'ادامه همه',
|
resumeAll: 'ادامه همه',
|
||||||
pauseAll: 'توقف همه',
|
pauseAll: 'توقف همه',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} مورد',
|
||||||
|
selected: '{{count}} انتخابشده',
|
||||||
|
downloaded: 'دانلودشده',
|
||||||
|
remaining: 'باقیمانده',
|
||||||
|
active: 'فعال',
|
||||||
|
unknown: 'نامشخص',
|
||||||
|
estimated: '{{value}} (تخمینی)',
|
||||||
|
},
|
||||||
queueEmpty: 'صف خالی است',
|
queueEmpty: 'صف خالی است',
|
||||||
noCompletedDownloads: 'هیچ دانلود تکمیلشدهای وجود ندارد',
|
noCompletedDownloads: 'هیچ دانلود تکمیلشدهای وجود ندارد',
|
||||||
noDownloads: 'هیچ دانلودی وجود ندارد',
|
noDownloads: 'هیچ دانلودی وجود ندارد',
|
||||||
|
|||||||
@@ -353,6 +353,15 @@ const he = {
|
|||||||
addDownload: 'הוספת הורדה',
|
addDownload: 'הוספת הורדה',
|
||||||
resumeAll: 'חידוש הכל',
|
resumeAll: 'חידוש הכל',
|
||||||
pauseAll: 'השהיית הכל',
|
pauseAll: 'השהיית הכל',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} פריטים',
|
||||||
|
selected: '{{count}} נבחרו',
|
||||||
|
downloaded: 'הורדו',
|
||||||
|
remaining: 'נותרו',
|
||||||
|
active: 'פעילות',
|
||||||
|
unknown: 'לא ידוע',
|
||||||
|
estimated: '{{value}} (הערכה)',
|
||||||
|
},
|
||||||
queueEmpty: 'התור ריק',
|
queueEmpty: 'התור ריק',
|
||||||
noCompletedDownloads: 'אין הורדות שהושלמו',
|
noCompletedDownloads: 'אין הורדות שהושלמו',
|
||||||
noDownloads: 'אין הורדות',
|
noDownloads: 'אין הורדות',
|
||||||
|
|||||||
@@ -353,6 +353,15 @@ const ru = {
|
|||||||
addDownload: 'Добавить загрузку',
|
addDownload: 'Добавить загрузку',
|
||||||
resumeAll: 'Возобновить все',
|
resumeAll: 'Возобновить все',
|
||||||
pauseAll: 'Приостановить все',
|
pauseAll: 'Приостановить все',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} элементов',
|
||||||
|
selected: 'Выбрано: {{count}}',
|
||||||
|
downloaded: 'Загружено',
|
||||||
|
remaining: 'Осталось',
|
||||||
|
active: 'Активные',
|
||||||
|
unknown: 'Неизвестно',
|
||||||
|
estimated: 'Около {{value}}',
|
||||||
|
},
|
||||||
queueEmpty: 'Очередь пуста',
|
queueEmpty: 'Очередь пуста',
|
||||||
noCompletedDownloads: 'Нет завершённых загрузок',
|
noCompletedDownloads: 'Нет завершённых загрузок',
|
||||||
noDownloads: 'Нет загрузок',
|
noDownloads: 'Нет загрузок',
|
||||||
|
|||||||
@@ -353,6 +353,15 @@ const uk = {
|
|||||||
addDownload: 'Додати завантаження',
|
addDownload: 'Додати завантаження',
|
||||||
resumeAll: 'Відновити всі',
|
resumeAll: 'Відновити всі',
|
||||||
pauseAll: 'Призупинити всі',
|
pauseAll: 'Призупинити всі',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} елементів',
|
||||||
|
selected: 'Вибрано: {{count}}',
|
||||||
|
downloaded: 'Завантажено',
|
||||||
|
remaining: 'Залишилось',
|
||||||
|
active: 'Активні',
|
||||||
|
unknown: 'Невідомо',
|
||||||
|
estimated: 'Приблизно {{value}}',
|
||||||
|
},
|
||||||
queueEmpty: 'Черга порожня',
|
queueEmpty: 'Черга порожня',
|
||||||
noCompletedDownloads: 'Немає завершених завантажень',
|
noCompletedDownloads: 'Немає завершених завантажень',
|
||||||
noDownloads: 'Немає завантажень',
|
noDownloads: 'Немає завантажень',
|
||||||
|
|||||||
@@ -353,6 +353,15 @@ const zhCN = {
|
|||||||
addDownload: '添加下载',
|
addDownload: '添加下载',
|
||||||
resumeAll: '全部恢复',
|
resumeAll: '全部恢复',
|
||||||
pauseAll: '全部暂停',
|
pauseAll: '全部暂停',
|
||||||
|
summary: {
|
||||||
|
items: '{{count}} 个项目',
|
||||||
|
selected: '已选择 {{count}}',
|
||||||
|
downloaded: '已下载',
|
||||||
|
remaining: '剩余',
|
||||||
|
active: '进行中',
|
||||||
|
unknown: '未知',
|
||||||
|
estimated: '估计 {{value}}',
|
||||||
|
},
|
||||||
queueEmpty: '队列为空',
|
queueEmpty: '队列为空',
|
||||||
noCompletedDownloads: '没有已完成的下载',
|
noCompletedDownloads: '没有已完成的下载',
|
||||||
noDownloads: '没有下载',
|
noDownloads: '没有下载',
|
||||||
|
|||||||
+37
-1
@@ -1805,9 +1805,11 @@ html[data-list-density="relaxed"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-content-header {
|
.downloads-content-header {
|
||||||
height: 58px;
|
min-height: 58px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
background: hsl(var(--main-bg));
|
background: hsl(var(--main-bg));
|
||||||
}
|
}
|
||||||
@@ -1837,6 +1839,40 @@ html[data-list-density="relaxed"] {
|
|||||||
background: hsl(var(--item-hover));
|
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 {
|
.downloads-table {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
canPauseDownload,
|
canPauseDownload,
|
||||||
canRedownload,
|
canRedownload,
|
||||||
canStartDownload,
|
canStartDownload,
|
||||||
|
getPauseResumeAction,
|
||||||
isIdentityLocked,
|
isIdentityLocked,
|
||||||
isTransferLocked,
|
isTransferLocked,
|
||||||
startActionLabel,
|
startActionLabel,
|
||||||
@@ -27,6 +28,18 @@ describe('download action policy', () => {
|
|||||||
expect(canRedownload('downloading')).toBe(false);
|
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', () => {
|
it('provides consistent labels and edit locks', () => {
|
||||||
expect(startActionLabel('ready')).toBe('Start');
|
expect(startActionLabel('ready')).toBe('Start');
|
||||||
expect(startActionLabel('failed')).toBe('Start');
|
expect(startActionLabel('failed')).toBe('Start');
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export const canStartDownload = (status: DownloadStatus): boolean =>
|
|||||||
export const canPauseDownload = (status: DownloadStatus): boolean =>
|
export const canPauseDownload = (status: DownloadStatus): boolean =>
|
||||||
PAUSABLE_STATUSES.has(status);
|
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 =>
|
export const canRedownload = (status: DownloadStatus): boolean =>
|
||||||
REDOWNLOADABLE_STATUSES.has(status);
|
REDOWNLOADABLE_STATUSES.has(status);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user