diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 20fc05b..985796c 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 } from 'lucide-react'; +import { Play, Pause, MoreVertical, Clock, RefreshCw } from 'lucide-react'; import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem'; import { canPauseDownload, @@ -28,6 +28,7 @@ import { interface DownloadItemProps { download: DownloadItemType; + allocationPending: boolean; queueIndex: number; columnOrder: DownloadTableColumnKey[]; columnAlignments: Record; @@ -51,6 +52,7 @@ interface DownloadItemProps { export const DownloadItem = React.memo(({ download, + allocationPending, queueIndex, columnOrder, columnAlignments, @@ -200,14 +202,18 @@ export const DownloadItem = React.memo(({ status: download.status, }); const displayPercent = `${(displayFraction * 100).toFixed(0)}%`; - const displaySpeed = download.status === 'seeding' + const displaySpeed = allocationPending + ? '-' + : download.status === 'seeding' ? liveProgress?.upload_speed ?? '-' : download.status === 'downloading' || download.status === 'verifying' ? liveProgress?.speed ?? download.speed : download.status === '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 ? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language) : '-' @@ -228,7 +234,9 @@ export const DownloadItem = React.memo(({ const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback; 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' ? download.status === 'retrying' && download.lastResolverFallback === true ? t($ => $.downloads.errors.nameResolutionRetrying) @@ -318,9 +326,10 @@ export const DownloadItem = React.memo(({ ) : (
-
+
(({ download.status === 'queued' || download.status === 'staged' ? 'queued' : download.status === 'retrying' ? 'retrying' : '' }`} - style={{ width: `${displayFraction * 100}%` }} + style={{ width: allocationPending ? undefined : `${displayFraction * 100}%` }} />
(({ : downloadStatusLabel } className={`download-status flex items-center gap-1.5 ${ + allocationPending ? 'download-status-downloading' : download.status === 'paused' ? 'download-status-paused' : download.status === 'seeding' ? 'download-status-seeding' : download.status === 'failed' ? 'download-status-failed' : @@ -360,7 +372,12 @@ export const DownloadItem = React.memo(({ download.status === 'retrying' ? 'download-status-retrying' : '' }`} > - {(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? ( + {allocationPending ? ( + <> +
-
$.properties.progress)} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}> -
+
$.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)} + > +
{progressPercent}
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
-
{t($ => $.properties.speed)}{snapshot.speed || '—'}
-
{t($ => $.properties.eta)}{snapshot.eta || '—'}
+
{t($ => $.properties.speed)}{allocationPending ? '—' : snapshot.speed || '—'}
+
{t($ => $.properties.eta)}{allocationPending ? '—' : snapshot.eta || '—'}
{connectionPresentation.showHeaderMetric &&
{connectionHeaderLabel}{connectionValue}
} {isTorrent && <>
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 02c1db7..4349f17 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -308,6 +308,7 @@ export const PropertiesWindowBridgeHost = () => { }, { queueName: queue?.name, windowChrome, + allocationPending: store.allocationPendingIds.has(downloadId), }), }); return true; @@ -688,7 +689,10 @@ export const PropertiesWindowBridgeHost = () => { snapshotRevisions.delete(windowLabel); clearWindowActionState(windowLabel); 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); } } diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index dd2c418..18a1b45 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -99,6 +99,7 @@ const common = { failed: 'Failed', retrying: 'Retrying', moving: 'Moving data', + allocatingFiles: 'Allocating files…', }, errors: { nameResolutionRetrying: 'Retrying with system network resolver', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 51986de..7836fbf 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -99,6 +99,7 @@ const fa = { failed: 'ناموفق', retrying: 'در حال تلاش مجدد', moving: 'در حال جابه‌جایی داده', + allocatingFiles: 'در حال تخصیص فایل‌ها…', }, errors: { nameResolutionRetrying: 'تلاش مجدد با DNS سیستم', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 324bd9f..206d8e8 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -99,6 +99,7 @@ const he = { failed: 'נכשל', retrying: 'ניסיון חוזר', moving: 'מעביר נתונים', + allocatingFiles: 'מקצה קבצים…', }, errors: { nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index b0a00ce..3a9f3e1 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -99,6 +99,7 @@ const ru = { failed: 'Ошибка', retrying: 'Повторная попытка', moving: 'Перемещение данных', + allocatingFiles: 'Выделение места под файлы…', }, errors: { nameResolutionRetrying: 'Повторная попытка через системный DNS', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index d9a8d36..ce97692 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -99,6 +99,7 @@ const uk = { failed: 'Помилка', retrying: 'Повторна спроба', moving: 'Переміщення даних', + allocatingFiles: 'Виділення місця для файлів…', }, errors: { nameResolutionRetrying: 'Повторна спроба через системний DNS', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index eaecd19..feb9f3b 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -99,6 +99,7 @@ const zhCN = { failed: '失败', retrying: '重试中', moving: '正在移动数据', + allocatingFiles: '正在分配文件空间…', }, errors: { nameResolutionRetrying: '正在使用系统 DNS 重试', diff --git a/src/index.css b/src/index.css index 6232031..51ab079 100644 --- a/src/index.css +++ b/src/index.css @@ -782,6 +782,11 @@ html[data-list-density="relaxed"] { .properties-progress-processing { background: hsl(199 89% 48%); } .properties-progress-queued { background: hsl(var(--status-queued)); } .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 { min-width: 42px; @@ -1439,8 +1444,13 @@ html[data-list-density="relaxed"] { } @media (prefers-reduced-motion: reduce) { - .properties-window-progress-fill { + .properties-window-progress-fill, + .download-progress-fill.allocating, + .properties-progress-allocating { transition: none; + animation: none; + transform: none; + opacity: 0.75; } .properties-window-tab { @@ -4273,6 +4283,12 @@ html[dir="rtl"] .download-context-menu-chevron { 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 { font-weight: 500; } @@ -4458,6 +4474,11 @@ html[dir="rtl"] .download-context-menu-chevron { 50% { opacity: 0.6; } } +@keyframes allocation-progress-indeterminate { + 0% { transform: translateX(-140%); } + 100% { transform: translateX(320%); } +} + @keyframes modal-in { from { opacity: 0; transform: translateY(4px) scale(0.99); } to { opacity: 1; transform: scale(1); } diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index ec6f57e..c8ab40d 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -258,6 +258,26 @@ describe('Properties window bridge', () => { 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', () => { const snapshot = sanitizePropertiesSnapshot({ id: 'media-1', diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 421ce9e..5553c2f 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -169,12 +169,14 @@ type SafePropertiesFields = Pick { useDownloadStore.setState({ downloads: [], backendRegisteredIds: new Set(), + allocationPendingIds: new Set(), pendingOrder: [], isAddModalOpen: false, pendingAddUrls: '', @@ -1279,6 +1280,67 @@ describe('useDownloadStore', () => { ).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 () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 6dd6bac..28ea596 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -434,7 +434,18 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): ) { 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; if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) { await removeStaleBackendDispatch(id); @@ -1014,8 +1025,10 @@ interface DownloadState { pendingOrder: string[]; setPendingOrder: (order: string[]) => void; backendRegisteredIds: Set; + allocationPendingIds: Set; registerBackendIds: (ids: string[]) => void; unregisterBackendIds: (ids: string[]) => void; + setAllocationPending: (id: string, pending: boolean) => void; applyProperties: (id: string, updates: Partial) => Promise; moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise; moveManyInQueueToPosition: ( @@ -1606,6 +1619,7 @@ export const useDownloadStore = create((set, get) => { } }, backendRegisteredIds: new Set(), + allocationPendingIds: new Set(), registerBackendIds: (ids) => set((state) => { const nextSet = new Set(state.backendRegisteredIds); for (const id of ids) nextSet.add(id); @@ -1616,6 +1630,12 @@ export const useDownloadStore = create((set, get) => { for (const id of ids) nextSet.delete(id); 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, pendingAddUrls: '', pendingAddReferer: '', @@ -1868,6 +1888,13 @@ export const useDownloadStore = create((set, get) => { : downloads, ...(updates.status === 'paused' ? { 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((set, get) => { pendingOrder: state.pendingOrder.filter(x => x !== id), backendRegisteredIds: new Set( Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id) + ), + allocationPendingIds: new Set( + Array.from(state.allocationPendingIds).filter(pendingId => pendingId !== id) ) })); try {