mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-20 16:12:17 +00:00
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:
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const fa = {
|
|||||||
failed: 'ناموفق',
|
failed: 'ناموفق',
|
||||||
retrying: 'در حال تلاش مجدد',
|
retrying: 'در حال تلاش مجدد',
|
||||||
moving: 'در حال جابهجایی داده',
|
moving: 'در حال جابهجایی داده',
|
||||||
|
allocatingFiles: 'در حال تخصیص فایلها…',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
nameResolutionRetrying: 'تلاش مجدد با DNS سیستم',
|
nameResolutionRetrying: 'تلاش مجدد با DNS سیستم',
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const he = {
|
|||||||
failed: 'נכשל',
|
failed: 'נכשל',
|
||||||
retrying: 'ניסיון חוזר',
|
retrying: 'ניסיון חוזר',
|
||||||
moving: 'מעביר נתונים',
|
moving: 'מעביר נתונים',
|
||||||
|
allocatingFiles: 'מקצה קבצים…',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת',
|
nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת',
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const ru = {
|
|||||||
failed: 'Ошибка',
|
failed: 'Ошибка',
|
||||||
retrying: 'Повторная попытка',
|
retrying: 'Повторная попытка',
|
||||||
moving: 'Перемещение данных',
|
moving: 'Перемещение данных',
|
||||||
|
allocatingFiles: 'Выделение места под файлы…',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
nameResolutionRetrying: 'Повторная попытка через системный DNS',
|
nameResolutionRetrying: 'Повторная попытка через системный DNS',
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const uk = {
|
|||||||
failed: 'Помилка',
|
failed: 'Помилка',
|
||||||
retrying: 'Повторна спроба',
|
retrying: 'Повторна спроба',
|
||||||
moving: 'Переміщення даних',
|
moving: 'Переміщення даних',
|
||||||
|
allocatingFiles: 'Виділення місця для файлів…',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
nameResolutionRetrying: 'Повторна спроба через системний DNS',
|
nameResolutionRetrying: 'Повторна спроба через системний DNS',
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const zhCN = {
|
|||||||
failed: '失败',
|
failed: '失败',
|
||||||
retrying: '重试中',
|
retrying: '重试中',
|
||||||
moving: '正在移动数据',
|
moving: '正在移动数据',
|
||||||
|
allocatingFiles: '正在分配文件空间…',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
nameResolutionRetrying: '正在使用系统 DNS 重试',
|
nameResolutionRetrying: '正在使用系统 DNS 重试',
|
||||||
|
|||||||
+22
-1
@@ -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); }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user