fix(ui): fence stale allocation status

- keep paused and completed rows authoritative over transient allocation state

- preserve allocation feedback for failed-download retries

- expose the table allocation phase as an accessible indeterminate progressbar
This commit is contained in:
NimBold
2026-08-16 00:09:21 +03:30
parent 6aa07db5df
commit bdbc11ad94
4 changed files with 40 additions and 10 deletions
+17 -9
View File
@@ -10,6 +10,7 @@ import {
} from '../utils/downloadActions'; } from '../utils/downloadActions';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
import { isAllocationPhaseVisible } from '../utils/downloads';
import { formatDateTime } from '../utils/dateTime'; import { formatDateTime } from '../utils/dateTime';
import { import {
downloadProgressColorClass, downloadProgressColorClass,
@@ -83,6 +84,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const [isActionHovered, setIsActionHovered] = React.useState(false); const [isActionHovered, setIsActionHovered] = React.useState(false);
const [isActionFocused, setIsActionFocused] = React.useState(false); const [isActionFocused, setIsActionFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>(); const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
const allocationVisible = isAllocationPhaseVisible(allocationPending, download.status);
const hasRowActions = download.status !== 'completed'; const hasRowActions = download.status !== 'completed';
const isBulkSelection = isSelected && selectedDownloadCount > 1; const isBulkSelection = isSelected && selectedDownloadCount > 1;
const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0 const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0
@@ -202,7 +204,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
status: download.status, status: download.status,
}); });
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`; const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
const displaySpeed = allocationPending const displaySpeed = allocationVisible
? '-' ? '-'
: download.status === 'seeding' : download.status === 'seeding'
? liveProgress?.upload_speed ?? '-' ? liveProgress?.upload_speed ?? '-'
@@ -211,7 +213,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
: download.status === 'processing' : download.status === 'processing'
? t($ => $.downloads.values.processing) ? t($ => $.downloads.values.processing)
: '-'; : '-';
const displayEta = allocationPending const displayEta = allocationVisible
? '-' ? '-'
: download.status === 'seeding' : download.status === 'seeding'
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0 ? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
@@ -234,7 +236,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback; const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value; return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})(); })();
const downloadStatusLabel = allocationPending const downloadStatusLabel = allocationVisible
? t($ => $.downloads.status.allocatingFiles) ? t($ => $.downloads.status.allocatingFiles)
: t($ => $.downloads.status[download.status]); : t($ => $.downloads.status[download.status]);
const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution' const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution'
@@ -326,10 +328,16 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</div> </div>
) : ( ) : (
<div className="download-cell-content download-status-content"> <div className="download-cell-content download-status-content">
<div className="download-progress-track" aria-label={allocationPending ? downloadStatusLabel : undefined}> <div
className="download-progress-track"
aria-label={allocationVisible ? downloadStatusLabel : undefined}
aria-busy={allocationVisible ? true : undefined}
aria-valuetext={allocationVisible ? downloadStatusLabel : undefined}
role={allocationVisible ? 'progressbar' : undefined}
>
<div <div
className={`download-progress-fill ${ className={`download-progress-fill ${
allocationPending ? 'allocating' : allocationVisible ? 'allocating' :
download.status === 'paused' ? 'paused' : download.status === 'paused' ? 'paused' :
download.status === 'seeding' ? 'seeding' : download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' : download.status === 'processing' ? 'processing' :
@@ -338,12 +346,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'queued' || download.status === 'staged' ? 'queued' : download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : '' download.status === 'retrying' ? 'retrying' : ''
}`} }`}
style={{ width: allocationPending ? undefined : `${displayFraction * 100}%` }} style={{ width: allocationVisible ? undefined : `${displayFraction * 100}%` }}
/> />
</div> </div>
<span <span
title={ title={
allocationPending allocationVisible
? downloadStatusLabel ? downloadStatusLabel
: download.lastError && ( : download.lastError && (
download.status === 'failed' download.status === 'failed'
@@ -360,7 +368,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
: downloadStatusLabel : downloadStatusLabel
} }
className={`download-status flex items-center gap-1.5 ${ className={`download-status flex items-center gap-1.5 ${
allocationPending ? 'download-status-downloading' : allocationVisible ? 'download-status-downloading' :
download.status === 'paused' ? 'download-status-paused' : download.status === 'paused' ? 'download-status-paused' :
download.status === 'seeding' ? 'download-status-seeding' : download.status === 'seeding' ? 'download-status-seeding' :
download.status === 'failed' ? 'download-status-failed' : download.status === 'failed' ? 'download-status-failed' :
@@ -372,7 +380,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'retrying' ? 'download-status-retrying' : '' download.status === 'retrying' ? 'download-status-retrying' : ''
}`} }`}
> >
{allocationPending ? ( {allocationVisible ? (
<> <>
<RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" /> <RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />
<span className="truncate">{downloadStatusLabel}</span> <span className="truncate">{downloadStatusLabel}</span>
+2 -1
View File
@@ -58,6 +58,7 @@ import {
TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_DISABLED,
TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION,
TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO,
isAllocationPhaseVisible,
type TorrentEncryptionPolicy, type TorrentEncryptionPolicy,
type TorrentFileAllocation, type TorrentFileAllocation,
} from '../utils/downloads'; } from '../utils/downloads';
@@ -1140,7 +1141,7 @@ export const PropertiesWindowApp = () => {
}); });
const isPromptFooter = footerActions.includes('keepEditing'); const isPromptFooter = footerActions.includes('keepEditing');
const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status); const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status);
const allocationPending = snapshot.allocationPending === true; const allocationPending = isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status);
const total = snapshot.size || (snapshot.totalBytes === undefined const total = snapshot.size || (snapshot.totalBytes === undefined
? t($ => $.addDownloads.unknownSize) ? t($ => $.addDownloads.unknownSize)
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
+11
View File
@@ -8,6 +8,7 @@ import {
canonicalizeDownloadFileName, canonicalizeDownloadFileName,
categoryForDownload, categoryForDownload,
categoryForFileName, categoryForFileName,
isAllocationPhaseVisible,
isValidTorrentExcludeTrackerList, isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList, isValidTorrentTrackerList,
normalizeTorrentEncryptionPolicy, normalizeTorrentEncryptionPolicy,
@@ -107,6 +108,16 @@ describe('download persistence progress snapshots', () => {
}); });
}); });
describe('allocation phase visibility', () => {
it('does not override paused or completed statuses', () => {
expect(isAllocationPhaseVisible(true, 'ready')).toBe(true);
expect(isAllocationPhaseVisible(true, 'failed')).toBe(true);
expect(isAllocationPhaseVisible(true, 'paused')).toBe(false);
expect(isAllocationPhaseVisible(true, 'completed')).toBe(false);
expect(isAllocationPhaseVisible(false, 'downloading')).toBe(false);
});
});
describe('Torrent tracker input validation', () => { describe('Torrent tracker input validation', () => {
it('accepts supported trackers separated by lines or commas', () => { it('accepts supported trackers separated by lines or commas', () => {
expect(isValidTorrentTrackerList( expect(isValidTorrentTrackerList(
+10
View File
@@ -45,6 +45,16 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
export const isTransferActiveStatus = (status: DownloadStatus): boolean => export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'retrying'; status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'retrying';
/**
* A transient allocation flag must never replace a terminal or user-paused
* status in the UI. Failed rows remain eligible because retry admission can
* begin from the failed state before the backend accepts the new lifecycle.
*/
export const isAllocationPhaseVisible = (
allocationPending: boolean,
status: DownloadStatus,
): boolean => allocationPending && status !== 'completed' && status !== 'paused';
export const DOWNLOAD_CONNECTIONS_MIN = 1; export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16; export const DOWNLOAD_CONNECTIONS_MAX = 16;