fix(downloads): harden queue reordering and summary stats

This commit is contained in:
NimBold
2026-07-23 06:23:04 +03:30
parent 3587fb0c0d
commit 3559c69968
13 changed files with 413 additions and 129 deletions
+34 -2
View File
@@ -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>
+6 -40
View File
@@ -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 (
+221 -41
View File
@@ -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}
+3 -1
View File
@@ -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?',
+3 -1
View File
@@ -380,7 +380,9 @@ const fa = {
clickToAdd: 'برای افزودن دانلودها، روی',
addButtonOr: 'یا',
toAddDownloads: 'کلیک کنید',
queueReorderHint: 'دسته را بکشید تا جابه‌جا شود. با Alt+فلش بالا/پایین نیز ردیف انتخاب‌شده جابه‌جا می‌شود.',
queueReorderHint: 'ردیف‌ها را بکشید تا مرتب شوند',
queueReorderShortcut: '{{key}} + فلش بالا/پایین ردیف انتخاب‌شده را جابه‌جا می‌کند',
queuePriorityControls: 'جابه‌جایی دانلودهای انتخاب‌شده در صف',
queueDragHandle: 'کشیدن {{fileName}} برای جابه‌جایی',
queueReorderFailed: 'جابه‌جایی دانلودها ناموفق بود',
nonResumableOne: '۱ دانلود از ادامه‌دادن پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید از ابتدا شروع کنید. آیا از توقف آن مطمئن هستید؟',
+3 -1
View File
@@ -380,7 +380,9 @@ const he = {
clickToAdd: 'לחץ על לחצן',
addButtonOr: 'או',
toAddDownloads: 'כדי להוסיף הורדות',
queueReorderHint: 'גררו את הידית כדי לשנות את הסדר. Alt+חץ למעלה/למטה מזיז גם את השורה הממוקדת.',
queueReorderHint: 'גררו שורות כדי לשנות את הסדר',
queueReorderShortcut: '{{key}} + חץ למעלה/למטה מזיז את השורה הממוקדת',
queuePriorityControls: 'הזזת ההורדות שנבחרו בתור',
queueDragHandle: 'גררו את {{fileName}} כדי לשנות את הסדר',
queueReorderFailed: 'לא ניתן לשנות את סדר ההורדות',
nonResumableOne: 'הורדה אחת אינה תומכת בחידוש. אם תשהה אותה, תצטרך להתחיל מהתחלה מאוחר יותר. האם ברצונך להשהות?',
+3 -1
View File
@@ -380,7 +380,9 @@ const ru = {
clickToAdd: 'Нажмите кнопку',
addButtonOr: 'или',
toAddDownloads: 'чтобы добавить загрузки',
queueReorderHint: 'Перетащите маркер для изменения порядка. Alt+стрелка вверх/вниз перемещает выбранную строку.',
queueReorderHint: 'Перетаскивайте строки, чтобы изменить порядок',
queueReorderShortcut: '{{key}} + стрелка вверх/вниз перемещает сфокусированную строку',
queuePriorityControls: 'Переместить выбранные загрузки в очереди',
queueDragHandle: 'Перетащите {{fileName}} для изменения порядка',
queueReorderFailed: 'Не удалось изменить порядок загрузок',
nonResumableOne: '1 загрузка не поддерживает возобновление. Если вы приостановите её, позже придётся начинать скачивание заново. Вы действительно хотите приостановить?',
+3 -1
View File
@@ -380,7 +380,9 @@ const uk = {
clickToAdd: 'Натисніть',
addButtonOr: 'або',
toAddDownloads: 'щоб додати завантаження',
queueReorderHint: 'Перетягніть маркер, щоб змінити порядок. Alt+стрілка вгору/вниз переміщує сфокусований рядок.',
queueReorderHint: 'Перетягуйте рядки, щоб змінити порядок',
queueReorderShortcut: '{{key}} + стрілка вгору/вниз переміщує сфокусований рядок',
queuePriorityControls: 'Перемістити вибрані завантаження в черзі',
queueDragHandle: 'Перетягніть {{fileName}}, щоб змінити порядок',
queueReorderFailed: 'Не вдалося змінити порядок завантажень',
nonResumableOne: '1 завантаження не підтримує відновлення. Якщо ви призупините його, вам доведеться почати спочатку пізніше. Ви впевнені, що хочете призупинити?',
+3 -1
View File
@@ -380,7 +380,9 @@ const zhCN = {
clickToAdd: '点击',
addButtonOr: '按钮或',
toAddDownloads: '来添加下载',
queueReorderHint: '拖动手柄调整顺序。Alt+上/下箭头也可移动当前行。',
queueReorderHint: '拖动行以调整顺序',
queueReorderShortcut: '{{key}} + 上/下箭头可移动当前行',
queuePriorityControls: '移动队列中选中的下载',
queueDragHandle: '拖动 {{fileName}} 调整顺序',
queueReorderFailed: '无法调整下载顺序',
nonResumableOne: '1 个下载不支持断点续传。如果您暂停它,稍后您将不得不重新开始。确定要暂停吗?',
+98 -34
View File
@@ -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;
}
+21
View File
@@ -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 }),
+14 -5
View File
@@ -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 ||
+1 -1
View File
@@ -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;