feat(ui): show file allocation phase

- expose transient allocation state around normal Aria2 enqueue

- render truthful indeterminate allocation status in the table and Properties window

- add localized copy, accessibility semantics, lifecycle cleanup, and regression tests
This commit is contained in:
NimBold
2026-08-16 00:03:43 +03:30
parent d135a17f1b
commit 6aa07db5df
15 changed files with 199 additions and 19 deletions
+25 -8
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { Play, Pause, MoreVertical, Clock } from 'lucide-react'; import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react';
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem'; import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { import {
canPauseDownload, canPauseDownload,
@@ -28,6 +28,7 @@ import {
interface DownloadItemProps { interface DownloadItemProps {
download: DownloadItemType; download: DownloadItemType;
allocationPending: boolean;
queueIndex: number; queueIndex: number;
columnOrder: DownloadTableColumnKey[]; columnOrder: DownloadTableColumnKey[];
columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>; columnAlignments: Record<DownloadTableColumnKey, DownloadColumnAlignment>;
@@ -51,6 +52,7 @@ interface DownloadItemProps {
export const DownloadItem = React.memo<DownloadItemProps>(({ export const DownloadItem = React.memo<DownloadItemProps>(({
download, download,
allocationPending,
queueIndex, queueIndex,
columnOrder, columnOrder,
columnAlignments, columnAlignments,
@@ -200,14 +202,18 @@ 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 = download.status === 'seeding' const displaySpeed = allocationPending
? '-'
: download.status === 'seeding'
? liveProgress?.upload_speed ?? '-' ? liveProgress?.upload_speed ?? '-'
: download.status === 'downloading' || download.status === 'verifying' : download.status === 'downloading' || download.status === 'verifying'
? liveProgress?.speed ?? download.speed ? liveProgress?.speed ?? download.speed
: download.status === 'processing' : download.status === 'processing'
? t($ => $.downloads.values.processing) ? t($ => $.downloads.values.processing)
: '-'; : '-';
const displayEta = download.status === 'seeding' const displayEta = allocationPending
? '-'
: 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
? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language) ? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language)
: '-' : '-'
@@ -228,7 +234,9 @@ 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 = t($ => $.downloads.status[download.status]); const downloadStatusLabel = allocationPending
? t($ => $.downloads.status.allocatingFiles)
: t($ => $.downloads.status[download.status]);
const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution' const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution'
? download.status === 'retrying' && download.lastResolverFallback === true ? download.status === 'retrying' && download.lastResolverFallback === true
? t($ => $.downloads.errors.nameResolutionRetrying) ? t($ => $.downloads.errors.nameResolutionRetrying)
@@ -318,9 +326,10 @@ 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"> <div className="download-progress-track" aria-label={allocationPending ? downloadStatusLabel : undefined}>
<div <div
className={`download-progress-fill ${ className={`download-progress-fill ${
allocationPending ? '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' :
@@ -329,12 +338,14 @@ 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: `${displayFraction * 100}%` }} style={{ width: allocationPending ? undefined : `${displayFraction * 100}%` }}
/> />
</div> </div>
<span <span
title={ title={
download.lastError && ( allocationPending
? downloadStatusLabel
: download.lastError && (
download.status === 'failed' download.status === 'failed'
|| download.status === 'retrying' || download.status === 'retrying'
|| download.lastErrorKind === 'destinationAccess' || download.lastErrorKind === 'destinationAccess'
@@ -349,6 +360,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' :
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' :
@@ -360,7 +372,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'retrying' ? 'download-status-retrying' : '' download.status === 'retrying' ? 'download-status-retrying' : ''
}`} }`}
> >
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? ( {allocationPending ? (
<>
<RefreshCw size={12} className="animate-spin motion-reduce:animate-none shrink-0" aria-hidden="true" />
<span className="truncate">{downloadStatusLabel}</span>
</>
) : (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
<> <>
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse motion-reduce:animate-none shrink-0' : 'shrink-0'} /> <Clock size={12} className={download.status === 'queued' ? 'animate-pulse motion-reduce:animate-none shrink-0' : 'shrink-0'} />
<span className="truncate"> <span className="truncate">
+3 -1
View File
@@ -161,7 +161,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
moveManyInQueueToPosition, moveManyInQueueToPosition,
startAll, startAll,
pauseAll, pauseAll,
startSelected startSelected,
allocationPendingIds
} = useDownloadStore(); } = useDownloadStore();
const progressMap = useDownloadProgressStore(state => state.progressMap); const progressMap = useDownloadProgressStore(state => state.progressMap);
const { addToast } = useToast(); const { addToast } = useToast();
@@ -2303,6 +2304,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<DownloadItemComponent <DownloadItemComponent
key={d.id} key={d.id}
download={d} download={d}
allocationPending={allocationPendingIds.has(d.id)}
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1} queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
columnOrder={orderedColumns} columnOrder={orderedColumns}
columnAlignments={columnAlignments} columnAlignments={columnAlignments}
+22 -7
View File
@@ -1140,10 +1140,13 @@ 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 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)}`);
const statusLabel = t($ => $.downloads.status[snapshot.status]); const statusLabel = allocationPending
? t($ => $.downloads.status.allocatingFiles)
: t($ => $.downloads.status[snapshot.status]);
const connectionPresentation = getPropertiesConnectionPresentation(snapshot); const connectionPresentation = getPropertiesConnectionPresentation(snapshot);
const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency' const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
? t($ => $.properties.fragmentConcurrency) ? t($ => $.properties.fragmentConcurrency)
@@ -1183,8 +1186,8 @@ export const PropertiesWindowApp = () => {
snapshot.queuePosition, snapshot.queuePosition,
position => t($ => $.properties.queuePosition, { position }), position => t($ => $.properties.queuePosition, { position }),
); );
const progressPercent = `${Math.round(progress * 100)}%`; const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
const statusTone = propertiesStatusTone(snapshot.status); const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
const lifecycleLabel = lifecycleAction === 'pause' const lifecycleLabel = lifecycleAction === 'pause'
? t($ => $.downloads.actions.pause) ? t($ => $.downloads.actions.pause)
: lifecycleAction === 'resume' : lifecycleAction === 'resume'
@@ -1269,15 +1272,27 @@ export const PropertiesWindowApp = () => {
</div> </div>
</div> </div>
<div className="properties-window-progress-row" dir="ltr"> <div className="properties-window-progress-row" dir="ltr">
<div className="properties-window-progress-track" aria-label={t($ => $.properties.progress)} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}> <div
<div className={`properties-window-progress-fill properties-progress-${statusTone}`} style={{ width: `${progress * 100}%` }} /> className="properties-window-progress-track"
aria-label={t($ => $.properties.progress)}
aria-busy={allocationPending}
aria-valuetext={allocationPending ? statusLabel : undefined}
role="progressbar"
aria-valuemin={allocationPending ? undefined : 0}
aria-valuemax={allocationPending ? undefined : 100}
aria-valuenow={allocationPending ? undefined : Math.round(progress * 100)}
>
<div
className={`properties-window-progress-fill ${allocationPending ? 'properties-progress-allocating' : `properties-progress-${statusTone}`}`}
style={{ width: allocationPending ? undefined : `${progress * 100}%` }}
/>
</div> </div>
<span className="properties-window-progress-percent">{progressPercent}</span> <span className="properties-window-progress-percent">{progressPercent}</span>
</div> </div>
<div className="properties-window-metrics" dir="ltr"> <div className="properties-window-metrics" dir="ltr">
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div> <div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div> <div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div>
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div> <div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{allocationPending ? '—' : snapshot.eta || '—'}</strong></div></div>
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span className={connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : undefined}>{connectionHeaderLabel}</span>{connectionValue}</div></div>} {connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span className={connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : undefined}>{connectionHeaderLabel}</span>{connectionValue}</div></div>}
{isTorrent && <> {isTorrent && <>
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div> <div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
@@ -308,6 +308,7 @@ export const PropertiesWindowBridgeHost = () => {
}, { }, {
queueName: queue?.name, queueName: queue?.name,
windowChrome, windowChrome,
allocationPending: store.allocationPendingIds.has(downloadId),
}), }),
}); });
return true; return true;
@@ -688,7 +689,10 @@ export const PropertiesWindowBridgeHost = () => {
snapshotRevisions.delete(windowLabel); snapshotRevisions.delete(windowLabel);
clearWindowActionState(windowLabel); clearWindowActionState(windowLabel);
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined); void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
} else if (next !== before) { } else if (
next !== before
|| state.allocationPendingIds.has(downloadId) !== previous.allocationPendingIds.has(downloadId)
) {
snapshotCoalescer.schedule(windowLabel); snapshotCoalescer.schedule(windowLabel);
} }
} }
+1
View File
@@ -99,6 +99,7 @@ const common = {
failed: 'Failed', failed: 'Failed',
retrying: 'Retrying', retrying: 'Retrying',
moving: 'Moving data', moving: 'Moving data',
allocatingFiles: 'Allocating files…',
}, },
errors: { errors: {
nameResolutionRetrying: 'Retrying with system network resolver', nameResolutionRetrying: 'Retrying with system network resolver',
+1
View File
@@ -99,6 +99,7 @@ const fa = {
failed: 'ناموفق', failed: 'ناموفق',
retrying: 'در حال تلاش مجدد', retrying: 'در حال تلاش مجدد',
moving: 'در حال جابه‌جایی داده', moving: 'در حال جابه‌جایی داده',
allocatingFiles: 'در حال تخصیص فایل‌ها…',
}, },
errors: { errors: {
nameResolutionRetrying: 'تلاش مجدد با DNS سیستم', nameResolutionRetrying: 'تلاش مجدد با DNS سیستم',
+1
View File
@@ -99,6 +99,7 @@ const he = {
failed: 'נכשל', failed: 'נכשל',
retrying: 'ניסיון חוזר', retrying: 'ניסיון חוזר',
moving: 'מעביר נתונים', moving: 'מעביר נתונים',
allocatingFiles: 'מקצה קבצים…',
}, },
errors: { errors: {
nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת', nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת',
+1
View File
@@ -99,6 +99,7 @@ const ru = {
failed: 'Ошибка', failed: 'Ошибка',
retrying: 'Повторная попытка', retrying: 'Повторная попытка',
moving: 'Перемещение данных', moving: 'Перемещение данных',
allocatingFiles: 'Выделение места под файлы…',
}, },
errors: { errors: {
nameResolutionRetrying: 'Повторная попытка через системный DNS', nameResolutionRetrying: 'Повторная попытка через системный DNS',
+1
View File
@@ -99,6 +99,7 @@ const uk = {
failed: 'Помилка', failed: 'Помилка',
retrying: 'Повторна спроба', retrying: 'Повторна спроба',
moving: 'Переміщення даних', moving: 'Переміщення даних',
allocatingFiles: 'Виділення місця для файлів…',
}, },
errors: { errors: {
nameResolutionRetrying: 'Повторна спроба через системний DNS', nameResolutionRetrying: 'Повторна спроба через системний DNS',
+1
View File
@@ -99,6 +99,7 @@ const zhCN = {
failed: '失败', failed: '失败',
retrying: '重试中', retrying: '重试中',
moving: '正在移动数据', moving: '正在移动数据',
allocatingFiles: '正在分配文件空间…',
}, },
errors: { errors: {
nameResolutionRetrying: '正在使用系统 DNS 重试', nameResolutionRetrying: '正在使用系统 DNS 重试',
+22 -1
View File
@@ -782,6 +782,11 @@ html[data-list-density="relaxed"] {
.properties-progress-processing { background: hsl(199 89% 48%); } .properties-progress-processing { background: hsl(199 89% 48%); }
.properties-progress-queued { background: hsl(var(--status-queued)); } .properties-progress-queued { background: hsl(var(--status-queued)); }
.properties-progress-retrying { background: hsl(var(--status-retrying)); } .properties-progress-retrying { background: hsl(var(--status-retrying)); }
.properties-progress-allocating {
width: 35%;
background: hsl(var(--status-downloading));
animation: allocation-progress-indeterminate 1.2s ease-in-out infinite;
}
.properties-window-progress-percent { .properties-window-progress-percent {
min-width: 42px; min-width: 42px;
@@ -1439,8 +1444,13 @@ html[data-list-density="relaxed"] {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.properties-window-progress-fill { .properties-window-progress-fill,
.download-progress-fill.allocating,
.properties-progress-allocating {
transition: none; transition: none;
animation: none;
transform: none;
opacity: 0.75;
} }
.properties-window-tab { .properties-window-tab {
@@ -4273,6 +4283,12 @@ html[dir="rtl"] .download-context-menu-chevron {
animation: pulse-progress 1.5s ease-in-out infinite; animation: pulse-progress 1.5s ease-in-out infinite;
} }
.download-progress-fill.allocating {
width: 35%;
background: hsl(var(--status-downloading));
animation: allocation-progress-indeterminate 1.2s ease-in-out infinite;
}
.download-status { .download-status {
font-weight: 500; font-weight: 500;
} }
@@ -4458,6 +4474,11 @@ html[dir="rtl"] .download-context-menu-chevron {
50% { opacity: 0.6; } 50% { opacity: 0.6; }
} }
@keyframes allocation-progress-indeterminate {
0% { transform: translateX(-140%); }
100% { transform: translateX(320%); }
}
@keyframes modal-in { @keyframes modal-in {
from { opacity: 0; transform: translateY(4px) scale(0.99); } from { opacity: 0; transform: translateY(4px) scale(0.99); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }
+20
View File
@@ -258,6 +258,26 @@ describe('Properties window bridge', () => {
expect(snapshot.queueId).toBe('internal-queue-id'); expect(snapshot.queueId).toBe('internal-queue-id');
}); });
it('projects the transient allocation phase without changing the persisted download status', () => {
const snapshot = sanitizePropertiesSnapshot({
id: 'allocating-1',
fileName: 'large.bin',
url: 'https://example.test/file',
status: 'downloading',
category: 'Other',
dateAdded: '',
} as DownloadItem, {
theme: 'dark',
fontFamily: 'system',
appFontSize: 'standard',
listRowDensity: 'standard',
locale: 'en',
}, undefined, { allocationPending: true });
expect(snapshot.status).toBe('downloading');
expect(snapshot.allocationPending).toBe(true);
});
it('does not project Aria2 connection telemetry onto media snapshots', () => { it('does not project Aria2 connection telemetry onto media snapshots', () => {
const snapshot = sanitizePropertiesSnapshot({ const snapshot = sanitizePropertiesSnapshot({
id: 'media-1', id: 'media-1',
+3
View File
@@ -169,12 +169,14 @@ type SafePropertiesFields = Pick<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)
export type PropertiesSnapshotContext = { export type PropertiesSnapshotContext = {
queueName?: string; queueName?: string;
windowChrome?: PropertiesWindowChrome; windowChrome?: PropertiesWindowChrome;
allocationPending?: boolean;
}; };
export type PropertiesSnapshot = SafePropertiesFields & { export type PropertiesSnapshot = SafePropertiesFields & {
appearance: DocumentAppearance; appearance: DocumentAppearance;
windowChrome: PropertiesWindowChrome; windowChrome: PropertiesWindowChrome;
queueName?: string; queueName?: string;
allocationPending?: boolean;
lastErrorKind?: DownloadErrorKind; lastErrorKind?: DownloadErrorKind;
lastResolverFallback?: boolean; lastResolverFallback?: boolean;
activeConnections?: number; activeConnections?: number;
@@ -411,6 +413,7 @@ const copyWithoutSecrets = (
windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME, windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME,
...(lastErrorKind ? { lastErrorKind } : {}), ...(lastErrorKind ? { lastErrorKind } : {}),
...(context?.queueName ? { queueName: context.queueName } : {}), ...(context?.queueName ? { queueName: context.queueName } : {}),
...(context?.allocationPending === true ? { allocationPending: true } : {}),
...(live?.progress ? { ...(live?.progress ? {
fraction: live.progress.fraction, fraction: live.progress.fraction,
speed: item.status === 'seeding' speed: item.status === 'seeding'
+62
View File
@@ -94,6 +94,7 @@ describe('useDownloadStore', () => {
useDownloadStore.setState({ useDownloadStore.setState({
downloads: [], downloads: [],
backendRegisteredIds: new Set(), backendRegisteredIds: new Set(),
allocationPendingIds: new Set(),
pendingOrder: [], pendingOrder: [],
isAddModalOpen: false, isAddModalOpen: false,
pendingAddUrls: '', pendingAddUrls: '',
@@ -1279,6 +1280,67 @@ describe('useDownloadStore', () => {
).toHaveLength(2); ).toHaveLength(2);
}); });
it('exposes an indeterminate allocation phase while normal enqueue is blocked', async () => {
useDownloadStore.setState({
downloads: [{
id: 'allocation-phase',
url: 'https://example.test/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: 'MAIN',
}] as any[],
backendRegisteredIds: new Set(),
allocationPendingIds: new Set(),
});
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
resolveEnqueue = resolve;
});
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
if (command === 'enqueue_download') return enqueue as never;
if (command === 'get_pending_order') return Promise.resolve(['allocation-phase']) as never;
return Promise.resolve(undefined) as never;
});
const dispatch = dispatchItem('allocation-phase');
await vi.waitFor(() => {
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(true);
});
resolveEnqueue({ id: 'allocation-phase', filename: 'file.bin' });
await expect(dispatch).resolves.toBe(true);
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(false);
});
it('clears allocation state when a terminal status wins the race', () => {
useDownloadStore.setState({
downloads: [{
id: 'allocation-terminal',
url: 'https://example.test/file.bin',
fileName: 'file.bin',
status: 'downloading',
category: 'Other',
dateAdded: '',
}] as any[],
allocationPendingIds: new Set(['allocation-terminal']),
});
useDownloadStore.getState().updateDownload('allocation-terminal', {
status: 'failed',
lastError: 'disk full',
});
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-terminal')).toBe(false);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'failed',
lastError: 'disk full',
});
});
it('re-enqueues queued transfer edits only after an obsolete dispatch is removed', async () => { it('re-enqueues queued transfer edits only after an obsolete dispatch is removed', async () => {
useDownloadStore.setState({ useDownloadStore.setState({
downloads: [ downloads: [
+31 -1
View File
@@ -434,7 +434,18 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
) { ) {
return false; return false;
} }
const accepted = await invoke('enqueue_download', { item: enqueueItem }); const showsAllocationPhase = item.isMedia !== true && item.isTorrent !== true;
if (showsAllocationPhase) {
useDownloadStore.getState().setAllocationPending(id, true);
}
let accepted;
try {
accepted = await invoke('enqueue_download', { item: enqueueItem });
} finally {
if (showsAllocationPhase) {
useDownloadStore.getState().setAllocationPending(id, false);
}
}
backendAccepted = true; backendAccepted = true;
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) { if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
await removeStaleBackendDispatch(id); await removeStaleBackendDispatch(id);
@@ -1014,8 +1025,10 @@ interface DownloadState {
pendingOrder: string[]; pendingOrder: string[];
setPendingOrder: (order: string[]) => void; setPendingOrder: (order: string[]) => void;
backendRegisteredIds: Set<string>; backendRegisteredIds: Set<string>;
allocationPendingIds: Set<string>;
registerBackendIds: (ids: string[]) => void; registerBackendIds: (ids: string[]) => void;
unregisterBackendIds: (ids: string[]) => void; unregisterBackendIds: (ids: string[]) => void;
setAllocationPending: (id: string, pending: boolean) => void;
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>; applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>; moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
moveManyInQueueToPosition: ( moveManyInQueueToPosition: (
@@ -1606,6 +1619,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} }
}, },
backendRegisteredIds: new Set(), backendRegisteredIds: new Set(),
allocationPendingIds: new Set(),
registerBackendIds: (ids) => set((state) => { registerBackendIds: (ids) => set((state) => {
const nextSet = new Set(state.backendRegisteredIds); const nextSet = new Set(state.backendRegisteredIds);
for (const id of ids) nextSet.add(id); for (const id of ids) nextSet.add(id);
@@ -1616,6 +1630,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
for (const id of ids) nextSet.delete(id); for (const id of ids) nextSet.delete(id);
return { backendRegisteredIds: nextSet }; return { backendRegisteredIds: nextSet };
}), }),
setAllocationPending: (id, pending) => set((state) => {
const nextSet = new Set(state.allocationPendingIds);
if (pending) nextSet.add(id);
else nextSet.delete(id);
return { allocationPendingIds: nextSet };
}),
isAddModalOpen: false, isAddModalOpen: false,
pendingAddUrls: '', pendingAddUrls: '',
pendingAddReferer: '', pendingAddReferer: '',
@@ -1868,6 +1888,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
: downloads, : downloads,
...(updates.status === 'paused' ...(updates.status === 'paused'
? { pendingOrder: state.pendingOrder.filter(value => value !== id) } ? { pendingOrder: state.pendingOrder.filter(value => value !== id) }
: {}),
...(updates.status && ['completed', 'failed', 'paused'].includes(updates.status)
? {
allocationPendingIds: new Set(
[...state.allocationPendingIds].filter(pendingId => pendingId !== id)
)
}
: {}) : {})
}; };
}); });
@@ -1905,6 +1932,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
pendingOrder: state.pendingOrder.filter(x => x !== id), pendingOrder: state.pendingOrder.filter(x => x !== id),
backendRegisteredIds: new Set( backendRegisteredIds: new Set(
Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id) Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id)
),
allocationPendingIds: new Set(
Array.from(state.allocationPendingIds).filter(pendingId => pendingId !== id)
) )
})); }));
try { try {