mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 01:17:48 +00:00
fix(downloads): harden queue reordering and summary stats
This commit is contained in:
+34
-2
@@ -2,7 +2,7 @@ import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from
|
||||
import { schedulerCompletionState } from './utils/schedulerCompletion';
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||
import { DownloadTable } from "./components/DownloadTable";
|
||||
import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
|
||||
import { KeychainPermissionModal } from './components/KeychainPermissionModal';
|
||||
import { extractValidDownloadUrls } from './utils/url';
|
||||
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||
@@ -28,6 +28,7 @@ import { PanelLeft } from 'lucide-react';
|
||||
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDownloadBytes } from './utils/downloadProgress';
|
||||
|
||||
const loadSettingsView = () => import('./components/SettingsView');
|
||||
const loadSchedulerView = () => import('./components/SchedulerView');
|
||||
@@ -166,6 +167,7 @@ function App() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const platform = usePlatformInfo();
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [downloadTableSummary, setDownloadTableSummary] = useState<DownloadTableStatusSummary | null>(null);
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
|
||||
|
||||
@@ -218,6 +220,16 @@ function App() {
|
||||
download.status === 'queued' || download.status === 'staged'
|
||||
).length;
|
||||
const doneCount = downloads.filter(download => download.status === 'completed').length;
|
||||
const handleDownloadTableSummaryChange = useCallback((summary: DownloadTableStatusSummary | null) => {
|
||||
setDownloadTableSummary(summary);
|
||||
}, []);
|
||||
const formatStatusSummaryBytes = (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;
|
||||
};
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
@@ -1062,7 +1074,12 @@ function App() {
|
||||
)}
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
<Suspense fallback={<PageLoadingFallback />}>
|
||||
{activeView === 'downloads' && <DownloadTable filter={filter} />}
|
||||
{activeView === 'downloads' && (
|
||||
<DownloadTable
|
||||
filter={filter}
|
||||
onSummaryChange={handleDownloadTableSummaryChange}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'settings' && <SettingsView />}
|
||||
{activeView === 'scheduler' && <SchedulerView />}
|
||||
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
||||
@@ -1073,6 +1090,21 @@ function App() {
|
||||
{/* Status Bar */}
|
||||
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
||||
<span>{t($ => $.status.ready)}</span>
|
||||
{activeView === 'downloads' && downloadTableSummary ? (
|
||||
<div className="app-statusbar-summary" dir="ltr" aria-live="polite">
|
||||
<span className="app-statusbar-summary-metric">
|
||||
<span dir="auto">{t($ => $.downloadTable.summary.downloaded)}</span>
|
||||
<strong dir="auto">{formatStatusSummaryBytes(downloadTableSummary.summary.downloadedBytes)}</strong>
|
||||
</span>
|
||||
<span className="app-statusbar-summary-metric">
|
||||
<span dir="auto">{t($ => $.downloadTable.summary.remaining)}</span>
|
||||
<strong dir="auto">{formatStatusSummaryBytes(
|
||||
downloadTableSummary.summary.remainingBytes,
|
||||
downloadTableSummary.summary.remainingIsEstimated
|
||||
)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-3 tabular-nums">
|
||||
<span>{t($ => $.status.active, { count: activeDownloadCount })}</span>
|
||||
<span>{t($ => $.status.queued, { count: queuedCount })}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown, GripVertical } from 'lucide-react';
|
||||
import { Play, Pause, MoreVertical, Clock } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -24,7 +24,6 @@ interface DownloadItemProps {
|
||||
download: DownloadItemType;
|
||||
index: number;
|
||||
queueIndex: number;
|
||||
queueLength: number;
|
||||
columnOrder: DownloadTableColumnKey[];
|
||||
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
|
||||
tableGridTemplate: string;
|
||||
@@ -37,7 +36,7 @@ interface DownloadItemProps {
|
||||
isQueueReorderable: boolean;
|
||||
isQueueDragSource: boolean;
|
||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||
}
|
||||
|
||||
@@ -45,7 +44,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download,
|
||||
index,
|
||||
queueIndex,
|
||||
queueLength,
|
||||
columnOrder,
|
||||
columnAlignments,
|
||||
tableGridTemplate,
|
||||
@@ -202,21 +200,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
style={columnStyle('File Name')}
|
||||
>
|
||||
<div className="download-cell-content">
|
||||
{isQueueReorderable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="download-queue-drag-handle app-icon-button"
|
||||
aria-label={t($ => $.downloadTable.queueDragHandle, { fileName: download.fileName })}
|
||||
title={t($ => $.downloadTable.queueDragHandle, { fileName: download.fileName })}
|
||||
onPointerDown={event => {
|
||||
event.stopPropagation();
|
||||
onQueueDragStart(download.id, event);
|
||||
}}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={13} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="shrink-0 text-text-muted">
|
||||
{getCategoryIcon(download.category)}
|
||||
</span>
|
||||
@@ -354,26 +337,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isQueueReorderable && queueIndex !== -1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onMoveInQueue(download.id, 'up')}
|
||||
disabled={queueIndex === 0}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title={t($ => $.downloads.actions.moveUp)}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMoveInQueue(download.id, 'down')}
|
||||
disabled={queueIndex === queueLength - 1}
|
||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||
title={t($ => $.downloads.actions.moveDown)}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canPauseDownload(download.status) && (
|
||||
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
|
||||
<Pause size={14} fill="currentColor" />
|
||||
@@ -401,7 +364,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<div
|
||||
ref={rowRef}
|
||||
data-download-id={download.id}
|
||||
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
|
||||
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueReorderable ? 'is-queue-reorderable' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => {
|
||||
@@ -424,6 +387,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
setIsRowFocused(false);
|
||||
}
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
if (isQueueReorderable) onQueueDragStart(download.id, event);
|
||||
}}
|
||||
onClick={(e) => onClick(e, download)}
|
||||
onKeyDown={event => {
|
||||
if (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SidebarFilter } from './Sidebar';
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
||||
import {
|
||||
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion,
|
||||
ArrowDownCircle, Command, ChevronRight, ChevronUp, ChevronDown, MoreHorizontal,
|
||||
ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronRight, ChevronUp, ChevronDown, MoreHorizontal,
|
||||
AlignLeft, AlignCenter, AlignRight, GripVertical
|
||||
} from 'lucide-react';
|
||||
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
canStartDownload
|
||||
} from '../utils/downloadActions';
|
||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||
import { formatDownloadBytes } from '../utils/downloadProgress';
|
||||
import { summarizeDownloads } from '../utils/downloadSummary';
|
||||
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
|
||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -50,11 +49,17 @@ import {
|
||||
type DownloadTableColumnKey
|
||||
} from '../utils/downloadTableColumns';
|
||||
import {
|
||||
moveSelectedBlockToIndex,
|
||||
targetIndexForBoundary
|
||||
} from '../utils/queueOrdering';
|
||||
|
||||
export interface DownloadTableStatusSummary {
|
||||
summary: DownloadSummary;
|
||||
}
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
onSummaryChange?: (summary: DownloadTableStatusSummary | null) => void;
|
||||
}
|
||||
|
||||
const persistColumnWidths = (widths: number[]): void => {
|
||||
@@ -108,13 +113,14 @@ interface QueueDragState {
|
||||
sourceId: string;
|
||||
queueId: string;
|
||||
ids: string[];
|
||||
startX: number;
|
||||
startY: number;
|
||||
active: boolean;
|
||||
targetIndex: number;
|
||||
markerTop: number;
|
||||
}
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
downloads,
|
||||
@@ -177,9 +183,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
|
||||
const queueListElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const queueReorderableDownloadsRef = useRef<DownloadItem[]>([]);
|
||||
const queueDragItemsRef = useRef<DownloadItem[]>([]);
|
||||
const queueDragBaseItemsRef = useRef<DownloadItem[]>([]);
|
||||
const queueDragStateRef = useRef<QueueDragState | null>(null);
|
||||
const queueDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const queueDragCaptureTargetRef = useRef<HTMLElement | null>(null);
|
||||
const queueDragCapturePointerIdRef = useRef<number | null>(null);
|
||||
const queueDragPreviewOrderRef = useRef<string[] | null>(null);
|
||||
const queueReorderPendingCountRef = useRef(0);
|
||||
const suppressQueueClickRef = useRef(false);
|
||||
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
|
||||
const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState<string[] | null>(null);
|
||||
selectedIdsRef.current = selectedIds;
|
||||
lastSelectedIdRef.current = lastSelectedId;
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
@@ -580,6 +594,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
columnDragCleanupRef.current = null;
|
||||
queueDragCleanupRef.current = null;
|
||||
queueDragStateRef.current = null;
|
||||
const queueCaptureTarget = queueDragCaptureTargetRef.current;
|
||||
const queueCapturePointerId = queueDragCapturePointerIdRef.current;
|
||||
queueDragCaptureTargetRef.current = null;
|
||||
queueDragCapturePointerIdRef.current = null;
|
||||
if (queueCaptureTarget && queueCapturePointerId !== null && queueCaptureTarget.hasPointerCapture(queueCapturePointerId)) {
|
||||
queueCaptureTarget.releasePointerCapture(queueCapturePointerId);
|
||||
}
|
||||
const captureTarget = columnDragCaptureTargetRef.current;
|
||||
const capturePointerId = columnDragCapturePointerIdRef.current;
|
||||
columnDragTargetRef.current = null;
|
||||
@@ -712,17 +733,62 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const clearQueueDragPreview = () => {
|
||||
queueDragPreviewOrderRef.current = null;
|
||||
queueDragBaseItemsRef.current = [];
|
||||
queueDragItemsRef.current = queueReorderableDownloadsRef.current;
|
||||
setQueueDragPreviewOrder(null);
|
||||
};
|
||||
|
||||
const releaseQueuePointerCapture = () => {
|
||||
const captureTarget = queueDragCaptureTargetRef.current;
|
||||
const capturePointerId = queueDragCapturePointerIdRef.current;
|
||||
queueDragCaptureTargetRef.current = null;
|
||||
queueDragCapturePointerIdRef.current = null;
|
||||
if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) {
|
||||
captureTarget.releasePointerCapture(capturePointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const trackQueueReorderOperation = (operation: Promise<void>): Promise<void> => {
|
||||
queueReorderPendingCountRef.current += 1;
|
||||
return operation.finally(() => {
|
||||
queueReorderPendingCountRef.current = Math.max(0, queueReorderPendingCountRef.current - 1);
|
||||
});
|
||||
};
|
||||
|
||||
const finishQueueDrag = (cancelled = false) => {
|
||||
const current = queueDragStateRef.current;
|
||||
if (!current) return;
|
||||
queueDragCleanupRef.current?.();
|
||||
queueDragCleanupRef.current = null;
|
||||
releaseQueuePointerCapture();
|
||||
queueDragStateRef.current = null;
|
||||
setQueueDragState(null);
|
||||
if (current.active) {
|
||||
suppressQueueClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressQueueClickRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (!cancelled && current.active) {
|
||||
void moveManyInQueueToPosition(current.ids, current.queueId, current.targetIndex)
|
||||
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
|
||||
const committedPreviewOrder = queueDragPreviewOrderRef.current;
|
||||
const reorderOperation = trackQueueReorderOperation(
|
||||
moveManyInQueueToPosition(current.ids, current.queueId, current.targetIndex)
|
||||
);
|
||||
void reorderOperation
|
||||
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error))
|
||||
.finally(() => {
|
||||
// Keep the optimistic order visible until the store has accepted or
|
||||
// rolled back the atomic backend move. A second drag must own its
|
||||
// own preview and cannot be cleared by this completion callback.
|
||||
if (queueDragPreviewOrderRef.current === committedPreviewOrder) {
|
||||
clearQueueDragPreview();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
clearQueueDragPreview();
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
@@ -732,9 +798,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
|
||||
const handleQueueDragStart = (
|
||||
id: string,
|
||||
event: React.PointerEvent<HTMLButtonElement>
|
||||
event: React.PointerEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (!queueReorderingEnabled || event.button !== 0) return;
|
||||
if (
|
||||
!queueReorderingEnabled ||
|
||||
event.button !== 0 ||
|
||||
queueDragStateRef.current ||
|
||||
queueDragPreviewOrderRef.current ||
|
||||
queueReorderPendingCountRef.current > 0
|
||||
) return;
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('button, a, input, textarea, select, [role="menu"], .download-row-actions')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentDownloads = useDownloadStore.getState().downloads;
|
||||
const source = currentDownloads.find(download => download.id === id);
|
||||
if (!source) return;
|
||||
@@ -758,33 +836,57 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}
|
||||
|
||||
queueDragCleanupRef.current?.();
|
||||
clearQueueDragPreview();
|
||||
const queueId = source.queueId || MAIN_QUEUE_ID;
|
||||
const selectedIdSet = new Set(ids);
|
||||
const initialItems = queueReorderableDownloadsRef.current
|
||||
const initialItems = queueDragItemsRef.current
|
||||
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId);
|
||||
queueDragBaseItemsRef.current = initialItems;
|
||||
const initialPosition = queueDropPosition(event.clientY, initialItems, selectedIdSet);
|
||||
const initialState: QueueDragState = {
|
||||
pointerId: event.pointerId,
|
||||
sourceId: id,
|
||||
queueId,
|
||||
ids,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
...initialPosition
|
||||
};
|
||||
queueDragStateRef.current = initialState;
|
||||
setQueueDragState(initialState);
|
||||
queueDragCaptureTargetRef.current = event.currentTarget;
|
||||
queueDragCapturePointerIdRef.current = event.pointerId;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// The pointer may have been cancelled between pointerdown and capture.
|
||||
// Window-level listeners remain the best-effort cleanup fallback.
|
||||
}
|
||||
|
||||
const pointerMove = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== event.pointerId) return;
|
||||
const drag = queueDragStateRef.current;
|
||||
if (!drag) return;
|
||||
const distance = Math.abs(pointerEvent.clientY - drag.startY);
|
||||
const distance = Math.hypot(
|
||||
pointerEvent.clientX - drag.startX,
|
||||
pointerEvent.clientY - drag.startY
|
||||
);
|
||||
if (!drag.active && distance < 5) return;
|
||||
|
||||
const items = queueReorderableDownloadsRef.current
|
||||
const items = queueDragItemsRef.current
|
||||
.filter(download => (download.queueId || MAIN_QUEUE_ID) === drag.queueId);
|
||||
const baseItems = queueDragBaseItemsRef.current;
|
||||
const nextPosition = queueDropPosition(pointerEvent.clientY, items, new Set(drag.ids));
|
||||
const previewItems = moveSelectedBlockToIndex(
|
||||
baseItems,
|
||||
new Set(drag.ids),
|
||||
nextPosition.targetIndex
|
||||
);
|
||||
queueDragItemsRef.current = previewItems;
|
||||
queueDragPreviewOrderRef.current = previewItems.map(item => item.id);
|
||||
setQueueDragPreviewOrder(queueDragPreviewOrderRef.current);
|
||||
suppressQueueClickRef.current = true;
|
||||
const nextState = { ...drag, ...nextPosition, active: true };
|
||||
queueDragStateRef.current = nextState;
|
||||
setQueueDragState(nextState);
|
||||
@@ -796,18 +898,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const pointerCancel = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === event.pointerId) finishQueueDrag(true);
|
||||
};
|
||||
const lostPointerCapture = () => finishQueueDrag(true);
|
||||
const cancel = () => finishQueueDrag(true);
|
||||
window.addEventListener('pointermove', pointerMove);
|
||||
window.addEventListener('pointerup', pointerUp);
|
||||
window.addEventListener('pointercancel', pointerCancel);
|
||||
window.addEventListener('blur', cancel);
|
||||
document.addEventListener('visibilitychange', cancel);
|
||||
event.currentTarget.addEventListener('lostpointercapture', lostPointerCapture);
|
||||
queueDragCleanupRef.current = () => {
|
||||
window.removeEventListener('pointermove', pointerMove);
|
||||
window.removeEventListener('pointerup', pointerUp);
|
||||
window.removeEventListener('pointercancel', pointerCancel);
|
||||
window.removeEventListener('blur', cancel);
|
||||
document.removeEventListener('visibilitychange', cancel);
|
||||
event.currentTarget.removeEventListener('lostpointercapture', lostPointerCapture);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -917,6 +1022,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
);
|
||||
queueReorderableDownloadsRef.current = queueReorderableDownloads;
|
||||
|
||||
const renderedDownloads = useMemo(() => {
|
||||
if (!queueDragPreviewOrder || !queueReorderingEnabled) return sortedDownloads;
|
||||
|
||||
const previewRank = new Map(queueDragPreviewOrder.map((id, index) => [id, index]));
|
||||
const previewItems = [...queueReorderableDownloads].sort((left, right) =>
|
||||
(previewRank.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(previewRank.get(right.id) ?? Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
let previewIndex = 0;
|
||||
return sortedDownloads.map(download => queueReorderableIds.has(download.id)
|
||||
? previewItems[previewIndex++] ?? download
|
||||
: download
|
||||
);
|
||||
}, [queueDragPreviewOrder, queueReorderableDownloads, queueReorderableIds, queueReorderingEnabled, sortedDownloads]);
|
||||
queueDragItemsRef.current = renderedDownloads.filter(download => queueReorderableIds.has(download.id));
|
||||
|
||||
useEffect(() => {
|
||||
const current = queueDragStateRef.current;
|
||||
if (!current) return;
|
||||
@@ -942,16 +1063,25 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
() => 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;
|
||||
};
|
||||
const selectedQueueItems = useMemo(
|
||||
() => queueReorderableDownloads.filter(download => selectedIds.has(download.id)),
|
||||
[queueReorderableDownloads, selectedIds]
|
||||
);
|
||||
const selectedQueueIndices = selectedQueueItems
|
||||
.map(item => queueReorderableDownloads.findIndex(candidate => candidate.id === item.id));
|
||||
const canMoveSelectedUp = selectedQueueIndices.length > 0 && Math.min(...selectedQueueIndices) > 0;
|
||||
const canMoveSelectedDown = selectedQueueIndices.length > 0 &&
|
||||
Math.max(...selectedQueueIndices) < queueReorderableDownloads.length - 1;
|
||||
const queueReorderPending = queueDragState !== null ||
|
||||
queueDragPreviewOrder !== null;
|
||||
|
||||
useEffect(() => {
|
||||
onSummaryChange?.({
|
||||
summary: downloadSummary,
|
||||
});
|
||||
}, [downloadSummary, onSummaryChange]);
|
||||
|
||||
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
|
||||
|
||||
// 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
|
||||
@@ -983,8 +1113,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
positions.set(download.id, { index, length: queueItems.length });
|
||||
});
|
||||
}
|
||||
if (queueDragPreviewOrder && queueReorderingEnabled) {
|
||||
queueDragPreviewOrder.forEach((id, index) => {
|
||||
positions.set(id, { index, length: queueDragPreviewOrder.length });
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}, [downloads]);
|
||||
}, [downloads, queueDragPreviewOrder, queueReorderingEnabled]);
|
||||
sortedDownloadsRef.current = sortedDownloads;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1000,6 +1135,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
setQueueSortConfig(null);
|
||||
}, [filter, isQueueFilter]);
|
||||
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
||||
if (suppressQueueClickRef.current) {
|
||||
suppressQueueClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (e.detail === 2) {
|
||||
handleDownloadDoubleClick(item);
|
||||
return;
|
||||
@@ -1049,10 +1188,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
}, [clampMenuPosition]);
|
||||
|
||||
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
|
||||
if (
|
||||
queueDragStateRef.current ||
|
||||
queueDragPreviewOrderRef.current
|
||||
) return;
|
||||
const ids = selectedIdsRef.current.has(id)
|
||||
? Array.from(selectedIdsRef.current)
|
||||
: id;
|
||||
void moveInQueue(ids, direction);
|
||||
void trackQueueReorderOperation(moveInQueue(ids, direction));
|
||||
}, [moveInQueue]);
|
||||
|
||||
const moveSelectedQueueItems = useCallback((ids: string[], direction: 'up' | 'down') => {
|
||||
if (
|
||||
queueDragStateRef.current ||
|
||||
queueDragPreviewOrderRef.current
|
||||
) return;
|
||||
void trackQueueReorderOperation(moveInQueue(ids, direction));
|
||||
}, [moveInQueue]);
|
||||
|
||||
const handleSort = (column: DownloadSortColumn) => {
|
||||
@@ -1275,25 +1426,55 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
{getFilterTitle()}
|
||||
<span className="downloads-count">{sortedDownloads.length}</span>
|
||||
{queueReorderingEnabled && queueReorderableDownloads.length > 0 ? (
|
||||
<span className="downloads-queue-reorder-hint">
|
||||
{t($ => $.downloadTable.queueReorderHint)}
|
||||
<span className="downloads-queue-reorder-hint" title={t($ => $.downloadTable.queueReorderShortcut, { key: isMac ? 'Option' : 'Alt' })}>
|
||||
<span>{t($ => $.downloadTable.queueReorderHint)}</span>
|
||||
<span
|
||||
className="downloads-queue-reorder-shortcut"
|
||||
aria-label={t($ => $.downloadTable.queueReorderShortcut, { key: isMac ? 'Option' : 'Alt' })}
|
||||
>
|
||||
<kbd>{isMac ? 'Option' : 'Alt'}</kbd>
|
||||
<span aria-hidden="true">+</span>
|
||||
<kbd>↑</kbd>
|
||||
<span aria-hidden="true">/</span>
|
||||
<kbd>↓</kbd>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</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 className="downloads-header-actions">
|
||||
{selectedDownloads.length > 0 ? (
|
||||
<span className="downloads-selection-status">
|
||||
{t($ => $.downloadTable.summary.selected, { count: selectedDownloads.length })}
|
||||
</span>
|
||||
) : null}
|
||||
{queueReorderingEnabled && queueReorderableDownloads.length > 0 ? (
|
||||
<div
|
||||
className="downloads-queue-priority-controls"
|
||||
role="group"
|
||||
aria-label={t($ => $.downloadTable.queuePriorityControls)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="app-icon-button h-7 w-7"
|
||||
disabled={queueReorderPending || !canMoveSelectedUp}
|
||||
aria-label={t($ => $.downloads.actions.moveUp)}
|
||||
title={t($ => $.downloads.actions.moveUp)}
|
||||
onClick={() => moveSelectedQueueItems(selectedQueueItems.map(item => item.id), 'up')}
|
||||
>
|
||||
<ArrowUp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-icon-button h-7 w-7"
|
||||
disabled={queueReorderPending || !canMoveSelectedDown}
|
||||
aria-label={t($ => $.downloads.actions.moveDown)}
|
||||
title={t($ => $.downloads.actions.moveDown)}
|
||||
onClick={() => moveSelectedQueueItems(selectedQueueItems.map(item => item.id), 'down')}
|
||||
>
|
||||
<ArrowDown size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1435,13 +1616,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sortedDownloads.map((d, index) => (
|
||||
{renderedDownloads.map((d, index) => (
|
||||
<DownloadItemComponent
|
||||
key={d.id}
|
||||
download={d}
|
||||
index={index}
|
||||
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
|
||||
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
|
||||
columnOrder={orderedColumns}
|
||||
columnAlignments={columnAlignments}
|
||||
tableGridTemplate={tableGridTemplate}
|
||||
|
||||
@@ -380,7 +380,9 @@ const common = {
|
||||
clickToAdd: 'Click',
|
||||
addButtonOr: 'button or',
|
||||
toAddDownloads: 'to add downloads',
|
||||
queueReorderHint: 'Drag the handle to reorder. Alt+Arrow Up/Down also moves the focused row.',
|
||||
queueReorderHint: 'Drag rows to reorder',
|
||||
queueReorderShortcut: '{{key}} + Arrow Up/Down moves the focused row',
|
||||
queuePriorityControls: 'Move selected downloads in the queue',
|
||||
queueDragHandle: 'Drag {{fileName}} to reorder',
|
||||
queueReorderFailed: 'Could not reorder downloads',
|
||||
nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?',
|
||||
|
||||
@@ -380,7 +380,9 @@ const fa = {
|
||||
clickToAdd: 'برای افزودن دانلودها، روی',
|
||||
addButtonOr: 'یا',
|
||||
toAddDownloads: 'کلیک کنید',
|
||||
queueReorderHint: 'دسته را بکشید تا جابهجا شود. با Alt+فلش بالا/پایین نیز ردیف انتخابشده جابهجا میشود.',
|
||||
queueReorderHint: 'ردیفها را بکشید تا مرتب شوند',
|
||||
queueReorderShortcut: '{{key}} + فلش بالا/پایین ردیف انتخابشده را جابهجا میکند',
|
||||
queuePriorityControls: 'جابهجایی دانلودهای انتخابشده در صف',
|
||||
queueDragHandle: 'کشیدن {{fileName}} برای جابهجایی',
|
||||
queueReorderFailed: 'جابهجایی دانلودها ناموفق بود',
|
||||
nonResumableOne: '۱ دانلود از ادامهدادن پشتیبانی نمیکند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
|
||||
|
||||
@@ -380,7 +380,9 @@ const he = {
|
||||
clickToAdd: 'לחץ על לחצן',
|
||||
addButtonOr: 'או',
|
||||
toAddDownloads: 'כדי להוסיף הורדות',
|
||||
queueReorderHint: 'גררו את הידית כדי לשנות את הסדר. Alt+חץ למעלה/למטה מזיז גם את השורה הממוקדת.',
|
||||
queueReorderHint: 'גררו שורות כדי לשנות את הסדר',
|
||||
queueReorderShortcut: '{{key}} + חץ למעלה/למטה מזיז את השורה הממוקדת',
|
||||
queuePriorityControls: 'הזזת ההורדות שנבחרו בתור',
|
||||
queueDragHandle: 'גררו את {{fileName}} כדי לשנות את הסדר',
|
||||
queueReorderFailed: 'לא ניתן לשנות את סדר ההורדות',
|
||||
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
|
||||
|
||||
@@ -380,7 +380,9 @@ const ru = {
|
||||
clickToAdd: 'Нажмите кнопку',
|
||||
addButtonOr: 'или',
|
||||
toAddDownloads: 'чтобы добавить загрузки',
|
||||
queueReorderHint: 'Перетащите маркер для изменения порядка. Alt+стрелка вверх/вниз перемещает выбранную строку.',
|
||||
queueReorderHint: 'Перетаскивайте строки, чтобы изменить порядок',
|
||||
queueReorderShortcut: '{{key}} + стрелка вверх/вниз перемещает сфокусированную строку',
|
||||
queuePriorityControls: 'Переместить выбранные загрузки в очереди',
|
||||
queueDragHandle: 'Перетащите {{fileName}} для изменения порядка',
|
||||
queueReorderFailed: 'Не удалось изменить порядок загрузок',
|
||||
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
|
||||
|
||||
@@ -380,7 +380,9 @@ const uk = {
|
||||
clickToAdd: 'Натисніть',
|
||||
addButtonOr: 'або',
|
||||
toAddDownloads: 'щоб додати завантаження',
|
||||
queueReorderHint: 'Перетягніть маркер, щоб змінити порядок. Alt+стрілка вгору/вниз переміщує сфокусований рядок.',
|
||||
queueReorderHint: 'Перетягуйте рядки, щоб змінити порядок',
|
||||
queueReorderShortcut: '{{key}} + стрілка вгору/вниз переміщує сфокусований рядок',
|
||||
queuePriorityControls: 'Перемістити вибрані завантаження в черзі',
|
||||
queueDragHandle: 'Перетягніть {{fileName}}, щоб змінити порядок',
|
||||
queueReorderFailed: 'Не вдалося змінити порядок завантажень',
|
||||
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
|
||||
|
||||
@@ -380,7 +380,9 @@ const zhCN = {
|
||||
clickToAdd: '点击',
|
||||
addButtonOr: '按钮或',
|
||||
toAddDownloads: '来添加下载',
|
||||
queueReorderHint: '拖动手柄调整顺序。Alt+上/下箭头也可移动当前行。',
|
||||
queueReorderHint: '拖动行以调整顺序',
|
||||
queueReorderShortcut: '{{key}} + 上/下箭头可移动当前行',
|
||||
queuePriorityControls: '移动队列中选中的下载',
|
||||
queueDragHandle: '拖动 {{fileName}} 调整顺序',
|
||||
queueReorderFailed: '无法调整下载顺序',
|
||||
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
|
||||
|
||||
+98
-34
@@ -1158,6 +1158,7 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.app-statusbar {
|
||||
position: relative;
|
||||
height: 26px;
|
||||
font-size: 10px;
|
||||
background: hsl(var(--statusbar-bg));
|
||||
@@ -1165,6 +1166,39 @@ html[data-list-density="relaxed"] {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-statusbar-summary {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: min(52%, 560px);
|
||||
overflow: hidden;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-statusbar-summary-metric {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-statusbar-summary-metric > span {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.app-statusbar-summary-metric > strong {
|
||||
overflow: hidden;
|
||||
color: hsl(var(--text-secondary));
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
height: 31px;
|
||||
border-radius: 8px;
|
||||
@@ -1866,38 +1900,39 @@ html[data-list-density="relaxed"] {
|
||||
background: hsl(var(--item-hover));
|
||||
}
|
||||
|
||||
.downloads-summary {
|
||||
.downloads-header-actions {
|
||||
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;
|
||||
gap: 10px;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.downloads-summary-scope {
|
||||
.downloads-selection-status {
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 11px;
|
||||
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-queue-priority-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
border: 1px solid hsl(var(--border-color));
|
||||
border-radius: 7px;
|
||||
background: hsl(var(--statusbar-bg) / 0.7);
|
||||
}
|
||||
|
||||
.downloads-summary-value {
|
||||
.downloads-queue-priority-controls .app-icon-button {
|
||||
color: hsl(var(--text-secondary));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.downloads-queue-priority-controls .app-icon-button:hover:not(:disabled),
|
||||
.downloads-queue-priority-controls .app-icon-button:focus-visible {
|
||||
color: hsl(var(--accent-color));
|
||||
background: hsl(var(--accent-color) / 0.14);
|
||||
}
|
||||
|
||||
.downloads-table {
|
||||
@@ -1961,12 +1996,52 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.downloads-queue-reorder-hint {
|
||||
max-width: 340px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloads-queue-reorder-hint > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloads-queue-reorder-shortcut {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 3px;
|
||||
direction: ltr;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.downloads-queue-reorder-shortcut kbd {
|
||||
min-width: 16px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(var(--border-color));
|
||||
border-bottom-color: hsl(var(--border-color) / 0.72);
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-secondary));
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.download-table-scroll {
|
||||
@@ -2215,23 +2290,12 @@ html[data-list-density="relaxed"] .download-ghost-row {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.download-queue-drag-handle {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 22px;
|
||||
margin-inline-start: -4px;
|
||||
color: hsl(var(--text-muted));
|
||||
.download-row.is-queue-reorderable {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.download-queue-drag-handle:hover,
|
||||
.download-queue-drag-handle:focus-visible {
|
||||
color: hsl(var(--text-primary));
|
||||
background: hsl(var(--item-hover));
|
||||
}
|
||||
|
||||
.download-queue-drag-handle:active {
|
||||
.download-row.is-queue-reorderable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,27 @@ describe('download summaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats completed downloads as having no remaining bytes despite stale progress', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('done', {
|
||||
status: 'completed',
|
||||
totalBytes: 40 * 1024 ** 2,
|
||||
downloadedBytes: 40 * 1024 ** 2,
|
||||
}),
|
||||
], {
|
||||
done: progress('done', {
|
||||
downloaded_bytes: 40 * 1024 ** 2 - 555 * 1024,
|
||||
total_bytes: 555 * 1024,
|
||||
}),
|
||||
})).toEqual({
|
||||
itemCount: 1,
|
||||
activeCount: 0,
|
||||
downloadedBytes: 40 * 1024 ** 2,
|
||||
remainingBytes: 0,
|
||||
remainingIsEstimated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not present partial byte totals as complete aggregates', () => {
|
||||
expect(summarizeDownloads([
|
||||
item('known', { totalBytes: 100, downloadedBytes: 20 }),
|
||||
|
||||
@@ -42,11 +42,13 @@ const effectiveByteState = (
|
||||
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 downloadedBytes = download.status === 'completed'
|
||||
? usableBytes(download.downloadedBytes) ??
|
||||
usableBytes(progress?.downloaded_bytes) ??
|
||||
totalBytes
|
||||
: usableBytes(progress?.downloaded_bytes) ??
|
||||
usableBytes(download.downloadedBytes) ??
|
||||
(canInferNoDownloadedBytes(download) ? 0 : undefined);
|
||||
const totalIsEstimate = usesStoredMediaTotal
|
||||
? storedTotalIsEstimate
|
||||
: (progress?.total_is_estimate ?? storedTotalIsEstimate) === true;
|
||||
@@ -89,6 +91,13 @@ export const summarizeDownloads = (
|
||||
}
|
||||
}
|
||||
|
||||
if (download.status === 'completed') {
|
||||
// A completed row is terminal even when a delayed progress event still
|
||||
// carries an old partial denominator. Never expose that stale value as
|
||||
// remaining work in the aggregate status bar.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
state.totalBytes === undefined ||
|
||||
state.downloadedBytes === undefined ||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DEFAULT_COLUMN_ORDER = [
|
||||
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
|
||||
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const;
|
||||
// Width of the fixed action rail shown while hovering a row.
|
||||
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 120;
|
||||
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 84;
|
||||
// Keep the fixed rail clear of the viewport edge and horizontal scrollbar.
|
||||
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user