From 3559c69968b712d3a50522fa32a49c0a834a7853 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 23 Jul 2026 06:23:04 +0330 Subject: [PATCH] fix(downloads): harden queue reordering and summary stats --- src/App.tsx | 36 +++- src/components/DownloadItem.tsx | 46 +----- src/components/DownloadTable.tsx | 262 +++++++++++++++++++++++++----- src/i18n/catalogs/en.ts | 4 +- src/i18n/catalogs/fa.ts | 4 +- src/i18n/catalogs/he.ts | 4 +- src/i18n/catalogs/ru.ts | 4 +- src/i18n/catalogs/uk.ts | 4 +- src/i18n/catalogs/zh-CN.ts | 4 +- src/index.css | 132 +++++++++++---- src/utils/downloadSummary.test.ts | 21 +++ src/utils/downloadSummary.ts | 19 ++- src/utils/downloadTableColumns.ts | 2 +- 13 files changed, 413 insertions(+), 129 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index dcc3367..778c19d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('all'); + const [downloadTableSummary, setDownloadTableSummary] = useState(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(null); @@ -1062,7 +1074,12 @@ function App() { )}
}> - {activeView === 'downloads' && } + {activeView === 'downloads' && ( + + )} {activeView === 'settings' && } {activeView === 'scheduler' && } {activeView === 'speedLimiter' && } @@ -1073,6 +1090,21 @@ function App() { {/* Status Bar */}
{t($ => $.status.ready)} + {activeView === 'downloads' && downloadTableSummary ? ( +
+ + {t($ => $.downloadTable.summary.downloaded)} + {formatStatusSummaryBytes(downloadTableSummary.summary.downloadedBytes)} + + + {t($ => $.downloadTable.summary.remaining)} + {formatStatusSummaryBytes( + downloadTableSummary.summary.remainingBytes, + downloadTableSummary.summary.remainingIsEstimated + )} + +
+ ) : null}
{t($ => $.status.active, { count: activeDownloadCount })} {t($ => $.status.queued, { count: queuedCount })} diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index fff8c1c..b586bca 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -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; 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) => void; + onQueueDragStart: (id: string, event: React.PointerEvent) => void; onClick: (e: React.MouseEvent, item: DownloadItemType) => void; } @@ -45,7 +44,6 @@ export const DownloadItem = React.memo(({ download, index, queueIndex, - queueLength, columnOrder, columnAlignments, tableGridTemplate, @@ -202,21 +200,6 @@ export const DownloadItem = React.memo(({ style={columnStyle('File Name')} >
- {isQueueReorderable ? ( - - ) : null} {getCategoryIcon(download.category)} @@ -354,26 +337,6 @@ export const DownloadItem = React.memo(({ onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > - {isQueueReorderable && queueIndex !== -1 && ( - <> - - - - )} {canPauseDownload(download.status) && ( + +
+ ) : null}
@@ -1435,13 +1616,12 @@ export const DownloadTable: React.FC = ({ filter }) => {
) : ( <> - {sortedDownloads.map((d, index) => ( + {renderedDownloads.map((d, index) => ( 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; } diff --git a/src/utils/downloadSummary.test.ts b/src/utils/downloadSummary.test.ts index 22a2b37..06a90fe 100644 --- a/src/utils/downloadSummary.test.ts +++ b/src/utils/downloadSummary.test.ts @@ -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 }), diff --git a/src/utils/downloadSummary.ts b/src/utils/downloadSummary.ts index 76cf76d..d487f9d 100644 --- a/src/utils/downloadSummary.ts +++ b/src/utils/downloadSummary.ts @@ -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 || diff --git a/src/utils/downloadTableColumns.ts b/src/utils/downloadTableColumns.ts index 9c282dd..599facd 100644 --- a/src/utils/downloadTableColumns.ts +++ b/src/utils/downloadTableColumns.ts @@ -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;