import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; import { open, save } from '@tauri-apps/plugin-dialog'; import { Activity, Copy, Download, FileDown, FileText, FolderOpen, Gauge, Info, List, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, SlidersHorizontal, Timer, Upload, Users, Wrench, X, ChevronDown, ChevronUp } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot'; import type { TorrentDetails } from '../bindings/TorrentDetails'; import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot'; import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics'; import { invokeCommand as invoke } from '../ipc'; import { PROPERTIES_WINDOW_ACTION_RESULT, PROPERTIES_WINDOW_REMOVED, PROPERTIES_WINDOW_SNAPSHOT, attachAsyncPropertiesListener, DEFAULT_PROPERTIES_WINDOW_CHROME, encodePropertiesPatchValue, formatPropertiesQueuePlacement, getPropertiesLifecycleAction, propertiesDiagnosticRequestState, sendPropertiesActionRequest, sendPropertiesReady, isExpectedPropertiesDiagnosticUnavailable, nextPropertiesRequestId, propertiesDiagnosticPhase, propertiesActionRequestKey, propertiesWindowEventTarget, resetPropertiesActionState, type PropertiesAction, type PropertiesActionRequest, type PropertiesActionResult, type PropertiesPatch, type PropertiesDiagnosticPhase, type PropertiesSnapshot, type PropertiesSnapshotEvent, } from '../propertiesBridge'; import { formatDownloadBytes, formatTorrentRatio, resolveDownloadFraction } from '../utils/downloadProgress'; import { changeAppLocale } from '../i18n'; import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; import { getWindowControlRailWidth } from '../utils/windowControlStyle'; import { getPropertiesFooterActions } from '../utils/propertiesFooter'; import { formatPropertiesAvailability, formatPropertiesDiagnosticCount, getPropertiesAvailabilityDiagnosticState, getPropertiesPeerDiagnosticState, } from '../utils/propertiesDiagnostics'; import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl'; import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs'; import { WindowControls } from './WindowControls'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation, } from '../utils/downloads'; type SecretName = 'username' | 'password' | 'cookies' | 'headers'; type SecretDraft = { value: string; touched: boolean; clear: boolean }; const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers']; const isTorrentDiagnosticsStatus = (status: string) => ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status); const isTorrentPollingStatus = (status: string) => ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status); const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status); const isTorrentFileSelectionEditable = (status: string) => ['ready', 'staged', 'queued', 'paused', 'failed'].includes(status); const safeTitle = (name: string) => { const bounded = name.replace(/[\r\n\u0000]/g, ' ').trim().slice(0, 160); return `${bounded || 'Download'} - Properties - Firelink`; }; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); const PropertiesHelp = ({ text }: { text: string }) => ( ); const PropertiesField = ({ label, controlId, hint, meta, format, children, className = '', }: { label: ReactNode; controlId: string; hint?: string; meta?: ReactNode; format?: ReactNode; children: ReactNode; className?: string; }) => (
{hint && } {meta && {meta}}
{children} {format && {format}}
); const PropertiesOptionToggle = ({ label, hint, checked, disabled, onChange, }: { label: ReactNode; hint: string; checked: boolean; disabled: boolean; onChange: (checked: boolean) => void; }) => { const controlId = useId(); return (
onChange(event.target.checked)} disabled={disabled} />
); }; const propertiesStatusTone = (status: string) => { if (status === 'paused') return 'paused'; if (status === 'seeding') return 'seeding'; if (status === 'failed') return 'failed'; if (status === 'processing' || status === 'verifying' || status === 'moving') return 'processing'; if (status === 'queued' || status === 'staged') return 'queued'; if (status === 'retrying') return 'retrying'; if (status === 'completed') return 'completed'; return 'downloading'; }; const propertiesTabIcon = (tab: PropertiesTab) => { const props = { className: 'properties-window-tab-icon', size: 15, strokeWidth: 2, 'aria-hidden': true } as const; switch (tab) { case 'overview': return ; case 'files': return ; case 'trackers': return ; case 'peers': return ; case 'options': return ; case 'transfer': return ; case 'advanced': return ; } }; const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string => [ snapshot.id, snapshot.status, snapshot.lastTry ?? '', snapshot.hasBeenDispatched === true, snapshot.destination ?? '', snapshot.torrentInfoHash ?? '', snapshot.torrentFileIndices?.join(',') ?? '', snapshot.torrentMoveDestination ?? '', snapshot.torrentMoveRestoreStatus ?? '', snapshot.torrentVerifyOnly === true, snapshot.torrentRelocationCheckPending === true, ].join('\u0000'); export const PropertiesWindowApp = () => { const { t } = useTranslation(); const translationRef = useRef(t); translationRef.current = t; const currentWindow = useMemo(() => getCurrentWindow(), []); const windowLabel = currentWindow.label; const sessionId = useMemo(() => crypto.randomUUID(), []); const [downloadId, setDownloadId] = useState(null); const [snapshot, setSnapshot] = useState(null); const [isUrlExpanded, setIsUrlExpanded] = useState(false); const [urlHasOverflow, setUrlHasOverflow] = useState(false); const [useTabOverflow, setUseTabOverflow] = useState(false); const [activeTab, setActiveTab] = useState('overview'); const [pendingTab, setPendingTab] = useState(null); const [closePrompt, setClosePrompt] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [notice, setNotice] = useState(''); const [pendingAction, setPendingAction] = useState(null); const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | null>(null); const [fileProgress, setFileProgress] = useState(null); const [peers, setPeers] = useState(null); const [availability, setAvailability] = useState(null); const [details, setDetails] = useState(null); const [diagnosticError, setDiagnosticError] = useState(''); const [diagnosticsLoading, setDiagnosticsLoading] = useState(false); const [diagnosticsRefreshing, setDiagnosticsRefreshing] = useState(false); const [diagnosticPhase, setDiagnosticPhase] = useState('idle'); const [peerDiagnosticPhase, setPeerDiagnosticPhase] = useState('idle'); const [availabilityDiagnosticPhase, setAvailabilityDiagnosticPhase] = useState('idle'); // null means the Files tab has no local selection draft yet; [] is an // explicit user choice to clear every file and must remain visually empty. const [selectedFiles, setSelectedFiles] = useState(null); const [fileName, setFileName] = useState(''); const [destination, setDestination] = useState(''); const [connections, setConnections] = useState(''); const [trackers, setTrackers] = useState(''); const [excludedTrackers, setExcludedTrackers] = useState(''); const [downloadLimit, setDownloadLimit] = useState(''); const [uploadLimit, setUploadLimit] = useState(''); const [maxPeers, setMaxPeers] = useState(''); const [peerSpeedLimit, setPeerSpeedLimit] = useState(''); const [seedTime, setSeedTime] = useState(''); const [seedRatio, setSeedRatio] = useState(''); const [checkIntegrity, setCheckIntegrity] = useState(false); const [removeUnselectedFile, setRemoveUnselectedFile] = useState(false); const [stopTimeout, setStopTimeout] = useState(''); const [prioritizePiece, setPrioritizePiece] = useState(''); const [encryptionPolicy, setEncryptionPolicy] = useState(''); const [fileAllocation, setFileAllocation] = useState(''); const [trackerConnectTimeout, setTrackerConnectTimeout] = useState(''); const [trackerTimeout, setTrackerTimeout] = useState(''); const [trackerInterval, setTrackerInterval] = useState(''); const [secretDrafts, setSecretDrafts] = useState>({ username: { value: '', touched: false, clear: false }, password: { value: '', touched: false, clear: false }, cookies: { value: '', touched: false, clear: false }, headers: { value: '', touched: false, clear: false }, }); const [draftTab, setDraftTab] = useState(null); const draftTabRef = useRef(null); const closeAfterSaveRef = useRef(false); const switchAfterSaveRef = useRef(null); const requestIdRef = useRef(0); const pendingActionRequestRef = useRef(null); const pendingActionRef = useRef(null); const pendingActionSendKeyRef = useRef(null); const latestSnapshotRevisionRef = useRef(0); const latestBridgeGenerationRef = useRef(null); const appearanceCleanupRef = useRef<(() => void) | null>(null); const hasRevealedWindowRef = useRef(false); const revealInFlightRef = useRef(false); const readyRetryTimerRef = useRef(undefined); const diagnosticsInFlightRef = useRef(new Set()); const snapshotRef = useRef(snapshot); const activeTabRef = useRef(activeTab); const downloadIdRef = useRef(downloadId); const fileProgressRef = useRef(fileProgress); const peersRef = useRef(peers); const availabilityRef = useRef(availability); const detailsRef = useRef(details); const diagnosticAttemptsRef = useRef(new Set()); const diagnosticLifecycleKeyRef = useRef(''); const diagnosticLifecycleEpochRef = useRef(0); const allowWindowCloseRef = useRef(false); const isDirtyRef = useRef(false); const windowChromeRef = useRef(DEFAULT_PROPERTIES_WINDOW_CHROME); const previousUrlDownloadIdRef = useRef(downloadId); const previousUrlRef = useRef(null); const urlCardRef = useRef(null); const urlValueRef = useRef(null); snapshotRef.current = snapshot; activeTabRef.current = activeTab; downloadIdRef.current = downloadId; fileProgressRef.current = fileProgress; peersRef.current = peers; availabilityRef.current = availability; detailsRef.current = details; const isTorrent = snapshot?.isTorrent === true; const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]); const peerDiagnosticState = getPropertiesPeerDiagnosticState(peers, diagnosticsLoading, peerDiagnosticPhase); const availabilityDiagnosticState = getPropertiesAvailabilityDiagnosticState(availability, diagnosticsLoading, availabilityDiagnosticPhase); const urlCanExpand = Boolean(snapshot && (shouldOfferPropertiesUrlExpansion(snapshot.url) || urlHasOverflow)); const isDirty = draftTab !== null; isDirtyRef.current = isDirty; if (isDirty && allowWindowCloseRef.current) { // A programmatic close is only authorized for the close request it // immediately follows. If the native close was vetoed by another owner // and the user starts editing again, restore the dirty-state guard. allowWindowCloseRef.current = false; } useEffect(() => { const nextUrl = snapshot?.url ?? null; if (shouldResetPropertiesUrlExpansion( previousUrlDownloadIdRef.current, downloadId, previousUrlRef.current, nextUrl, )) { setIsUrlExpanded(false); setUrlHasOverflow(false); } previousUrlDownloadIdRef.current = downloadId; previousUrlRef.current = nextUrl; }, [downloadId, snapshot?.url]); useEffect(() => { const updateOverflowState = () => setUseTabOverflow(shouldUsePropertiesTabOverflow(window.innerWidth)); updateOverflowState(); const media = typeof window.matchMedia === 'function' ? window.matchMedia(`(max-width: ${PROPERTIES_TABS_OVERFLOW_BREAKPOINT}px)`) : null; if (media && typeof media.addEventListener === 'function') { media.addEventListener('change', updateOverflowState); return () => media.removeEventListener('change', updateOverflowState); } if (media && typeof media.addListener === 'function') { media.addListener(updateOverflowState); return () => media.removeListener(updateOverflowState); } window.addEventListener('resize', updateOverflowState); return () => window.removeEventListener('resize', updateOverflowState); }, []); useLayoutEffect(() => { if (isUrlExpanded) return; const card = urlCardRef.current; const value = urlValueRef.current; if (!card || !value) { setUrlHasOverflow(false); return; } const measureOverflow = () => { setUrlHasOverflow(value.scrollHeight > value.clientHeight + 1); }; measureOverflow(); if (typeof ResizeObserver === 'function') { const observer = new ResizeObserver(measureOverflow); observer.observe(card); return () => observer.disconnect(); } window.addEventListener('resize', measureOverflow); return () => window.removeEventListener('resize', measureOverflow); }, [activeTab, isUrlExpanded, snapshot?.url, useTabOverflow]); const closeCurrentWindow = useCallback(async (allowDirtyClose = false) => { allowWindowCloseRef.current = allowDirtyClose; try { await currentWindow.close(); } catch (error) { setErrorMessage(errorText(error)); allowWindowCloseRef.current = false; } }, [currentWindow]); const revealWindow = useCallback(async () => { if (hasRevealedWindowRef.current) { if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) { window.clearInterval(readyRetryTimerRef.current); readyRetryTimerRef.current = undefined; } return; } if (revealInFlightRef.current) return; revealInFlightRef.current = true; try { await invoke('properties_window_reveal'); hasRevealedWindowRef.current = true; if (latestSnapshotRevisionRef.current > 0 && readyRetryTimerRef.current !== undefined) { window.clearInterval(readyRetryTimerRef.current); readyRetryTimerRef.current = undefined; } } catch (error) { setErrorMessage(errorText(error)); } finally { revealInFlightRef.current = false; } }, []); useEffect(() => { // Native creation is hidden. Reveal after React has committed the loading // shell so the first visible frame is styled and clipped by its radius, // independent of the bridge snapshot timing. void revealWindow(); }, [revealWindow]); useEffect(() => { draftTabRef.current = draftTab; }, [draftTab]); const hydrateDraft = useCallback((next: PropertiesSnapshot) => { setFileName(next.fileName); setDestination(next.destination ?? ''); setConnections(next.connections === undefined ? '' : String(next.connections)); setTrackers(next.torrentTrackers ?? ''); setExcludedTrackers(next.torrentExcludeTrackers ?? ''); setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null); setDownloadLimit(next.speedLimit ?? ''); setUploadLimit(next.torrentUploadLimit ?? ''); setMaxPeers(next.torrentMaxPeers === undefined ? '' : String(next.torrentMaxPeers)); setPeerSpeedLimit(next.torrentPeerSpeedLimit ?? ''); setSeedTime(next.torrentSeedTime === undefined ? '' : String(next.torrentSeedTime)); setSeedRatio(next.torrentSeedRatio === undefined ? '' : String(next.torrentSeedRatio)); setCheckIntegrity(next.torrentCheckIntegrity === true); setRemoveUnselectedFile(next.torrentRemoveUnselectedFile === true); setStopTimeout(next.torrentStopTimeout === undefined ? '' : String(next.torrentStopTimeout)); setPrioritizePiece(next.torrentPrioritizePiece ?? ''); setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? ''); setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? ''); setTrackerConnectTimeout(next.torrentTrackerConnectTimeout === undefined ? '' : String(next.torrentTrackerConnectTimeout)); setTrackerTimeout(next.torrentTrackerTimeout === undefined ? '' : String(next.torrentTrackerTimeout)); setTrackerInterval(next.torrentTrackerInterval === undefined ? '' : String(next.torrentTrackerInterval)); setSecretDrafts({ username: { value: '', touched: false, clear: false }, password: { value: '', touched: false, clear: false }, cookies: { value: '', touched: false, clear: false }, headers: { value: '', touched: false, clear: false }, }); }, []); const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string, manual = false) => { if (!isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '')) return; const diagnosticTabKey = `${id}:${tab}`; const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current; const requestKey = `${diagnosticTabKey}:${requestLifecycleEpoch}`; if (diagnosticsInFlightRef.current.has(requestKey)) return; diagnosticsInFlightRef.current.add(requestKey); const isCurrent = () => downloadIdRef.current === id && activeTabRef.current === tab && isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '') && diagnosticLifecycleEpochRef.current === requestLifecycleEpoch; const hasCachedResult = () => tab === 'files' ? fileProgressRef.current !== null : tab === 'peers' ? peersRef.current !== null || availabilityRef.current !== null : detailsRef.current !== null; const hasPreviousAttempt = diagnosticAttemptsRef.current.has(diagnosticTabKey); diagnosticAttemptsRef.current.add(diagnosticTabKey); if (isCurrent()) { const cached = hasCachedResult(); const requestState = propertiesDiagnosticRequestState(cached, hasPreviousAttempt, manual); setDiagnosticsLoading(requestState.loading); setDiagnosticsRefreshing(requestState.refreshing); if (tab === 'peers') { setPeerDiagnosticPhase(propertiesDiagnosticPhase( peersRef.current !== null, 'request-start', hasPreviousAttempt, )); setAvailabilityDiagnosticPhase(propertiesDiagnosticPhase( availabilityRef.current !== null, 'request-start', hasPreviousAttempt, )); } // A silent refresh with no cached result must not replace a stable // unavailable/error message between polling requests. Cached results // remain visible while their request is refreshed in the background. if (requestState.resetMessage) { setDiagnosticError(''); setDiagnosticPhase(requestState.phase); } } try { if (tab === 'overview') { const nextDetails = await invoke('get_torrent_details', { id }); if (isCurrent()) { setDetails(nextDetails); setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); } } else if (tab === 'files') { const nextProgress = await invoke('get_torrent_file_progress', { id }); if (isCurrent()) { setFileProgress(nextProgress); setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); } } else if (tab === 'peers') { const hadCachedPeers = peersRef.current !== null; const hadCachedAvailability = availabilityRef.current !== null; const [peerResult, availabilityResult] = await Promise.allSettled([ invoke('get_torrent_peers', { id }), invoke('get_torrent_availability', { id }), ]); if (isCurrent()) { if (peerResult.status === 'fulfilled') setPeers(peerResult.value); if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value); const peerOutcome = peerResult.status === 'fulfilled' ? 'success' : isExpectedPropertiesDiagnosticUnavailable(peerResult.reason) ? 'expected-unavailable' : 'unexpected-error'; const availabilityOutcome = availabilityResult.status === 'fulfilled' ? 'success' : isExpectedPropertiesDiagnosticUnavailable(availabilityResult.reason) ? 'expected-unavailable' : 'unexpected-error'; setPeerDiagnosticPhase(propertiesDiagnosticPhase(hadCachedPeers, peerOutcome)); setAvailabilityDiagnosticPhase(propertiesDiagnosticPhase(hadCachedAvailability, availabilityOutcome)); const rejectedResults = [peerResult, availabilityResult] .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map(result => result.reason); const unexpectedErrors = rejectedResults .filter(error => !isExpectedPropertiesDiagnosticUnavailable(error)); setDiagnosticError(unexpectedErrors.length > 0 ? errorText(unexpectedErrors[0]) : ''); if (rejectedResults.length > 0) { const hasPeerResult = hasCachedResult() || peerResult.status === 'fulfilled' || availabilityResult.status === 'fulfilled'; setDiagnosticPhase(propertiesDiagnosticPhase( hasPeerResult, unexpectedErrors.length > 0 ? 'unexpected-error' : 'expected-unavailable', )); } else setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); } } } catch (error) { if (isCurrent()) { const message = errorText(error); // A paused row may not have a retained Aria2 GID (for example when it // was paused before its first dispatch). That is an expected absence, // not a diagnostic failure, and must not flash a raw backend error. if (isExpectedPropertiesDiagnosticUnavailable(error)) { setDiagnosticError(''); setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'expected-unavailable')); } else { setDiagnosticError(message); setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'unexpected-error')); } if (tab === 'peers') { const hasCachedPeers = peersRef.current !== null; const hasCachedAvailability = availabilityRef.current !== null; const outcome = isExpectedPropertiesDiagnosticUnavailable(error) ? 'expected-unavailable' : 'unexpected-error'; setPeerDiagnosticPhase(propertiesDiagnosticPhase(hasCachedPeers, outcome)); setAvailabilityDiagnosticPhase(propertiesDiagnosticPhase(hasCachedAvailability, outcome)); } } } finally { diagnosticsInFlightRef.current.delete(requestKey); if (downloadIdRef.current === id && activeTabRef.current === tab && diagnosticLifecycleEpochRef.current === requestLifecycleEpoch) { setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); } } }, []); useEffect(() => { let cancelled = false; let readyHeartbeatTimer: number | undefined; let unlistenSnapshot: UnlistenFn | undefined; let unlistenResult: UnlistenFn | undefined; let unlistenRemoved: UnlistenFn | undefined; const start = async () => { try { const id = await invoke('get_properties_window_download_id'); if (cancelled) return; setDownloadId(id); const snapshotListener = await listen(PROPERTIES_WINDOW_SNAPSHOT, async event => { if (cancelled) return; if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id || event.payload.sessionId !== sessionId) return; if (latestBridgeGenerationRef.current !== null && event.payload.bridgeGeneration < latestBridgeGenerationRef.current) return; if (latestBridgeGenerationRef.current !== event.payload.bridgeGeneration) { latestBridgeGenerationRef.current = event.payload.bridgeGeneration; latestSnapshotRevisionRef.current = 0; diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); const lostAction = pendingActionRef.current; const lostDraftAction = lostAction === 'apply-properties' || lostAction === 'set-torrent-file-selection'; // A main-webview restart can lose both the action-result event and // the store transition event while the child Properties window // remains alive. No result from the dead bridge can be correlated // safely after this point, so release the UI lock after adopting // the next snapshot and require any retry to be an explicit user // action. This never replays a possibly completed lifecycle // request, and also recovers when the native request failed while // its failure event was lost. const resetActionState = resetPropertiesActionState(requestIdRef.current); requestIdRef.current = resetActionState.requestId; pendingActionRequestRef.current = resetActionState.request; pendingActionRef.current = resetActionState.pendingAction; setPendingAction(null); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; if (lostDraftAction) { // A completed property action is represented by the fresh // snapshot, not by the stale draft that produced the request. draftTabRef.current = null; setDraftTab(null); setPendingTab(null); setClosePrompt(false); } setPendingTorrentCommand(null); } if (event.payload.revision <= latestSnapshotRevisionRef.current) return; latestSnapshotRevisionRef.current = event.payload.revision; const nextDiagnosticLifecycleKey = propertiesDiagnosticLifecycleKey(event.payload.snapshot); if (diagnosticLifecycleKeyRef.current !== nextDiagnosticLifecycleKey) { diagnosticLifecycleKeyRef.current = nextDiagnosticLifecycleKey; diagnosticLifecycleEpochRef.current += 1; diagnosticAttemptsRef.current.clear(); // A changed Torrent lifecycle invalidates every diagnostic snapshot // from the previous lifecycle. Keep the old response out of the // loading and stale states while the new lifecycle is queried. setDetails(null); setFileProgress(null); setPeers(null); setAvailability(null); setDiagnosticError(''); setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); setDiagnosticPhase('idle'); setPeerDiagnosticPhase('idle'); setAvailabilityDiagnosticPhase('idle'); } await changeAppLocale(event.payload.snapshot.appearance.locale); if (cancelled || event.payload.bridgeGeneration !== latestBridgeGenerationRef.current || event.payload.revision !== latestSnapshotRevisionRef.current) return; appearanceCleanupRef.current?.(); appearanceCleanupRef.current = synchronizeDocumentAppearance( window, event.payload.snapshot.appearance, ); windowChromeRef.current = event.payload.snapshot.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME; setSnapshot(event.payload.snapshot); if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot); void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined); await revealWindow(); }, { target: propertiesWindowEventTarget(windowLabel) }); if (cancelled) { snapshotListener(); return; } unlistenSnapshot = snapshotListener; const resultListener = await listen(PROPERTIES_WINDOW_ACTION_RESULT, event => { if (cancelled) return; if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id || event.payload.sessionId !== sessionId) return; if (event.payload.requestId !== requestIdRef.current) return; if (pendingActionRef.current === null) return; const completedAction = pendingActionRef.current; pendingActionRef.current = null; pendingActionRequestRef.current = null; setPendingAction(null); if (!event.payload.ok) { setErrorMessage(event.payload.error ?? 'The action failed'); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; } else { const commitsDraft = completedAction === 'apply-properties' || completedAction === 'set-torrent-file-selection'; const nextTab = switchAfterSaveRef.current; const shouldClose = closeAfterSaveRef.current; switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setErrorMessage(''); setNotice(completedAction === 'apply-properties' ? translationRef.current($ => $.properties.saved) : ''); if (commitsDraft) { draftTabRef.current = null; setDraftTab(null); setClosePrompt(false); if (nextTab) { setActiveTab(nextTab); setPendingTab(null); } if (shouldClose) { void closeCurrentWindow(true); } } } }, { target: propertiesWindowEventTarget(windowLabel) }); if (cancelled) { resultListener(); return; } unlistenResult = resultListener; const removedListener = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => { if (cancelled) return; if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) { if (readyRetryTimerRef.current !== undefined) { window.clearInterval(readyRetryTimerRef.current); readyRetryTimerRef.current = undefined; } if (readyHeartbeatTimer !== undefined) { window.clearInterval(readyHeartbeatTimer); readyHeartbeatTimer = undefined; } diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); setSnapshot(null); draftTabRef.current = null; isDirtyRef.current = false; setDraftTab(null); setPendingTab(null); setClosePrompt(false); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; pendingActionRequestRef.current = null; pendingActionRef.current = null; pendingActionSendKeyRef.current = null; allowWindowCloseRef.current = false; setPendingAction(null); setNotice(translationRef.current($ => $.downloadTable.noDownloads)); } }, { target: propertiesWindowEventTarget(windowLabel) }); if (cancelled) { removedListener(); return; } unlistenRemoved = removedListener; if (cancelled) return; // Tauri event listeners are registered asynchronously. If the main // bridge was still installing its listener, the first ready event can // legitimately be missed; retry until the first snapshot confirms // the handshake. Install this before the first attempt so an IPC // rejection is recoverable as well as a missed event. readyRetryTimerRef.current = window.setInterval(() => { if (cancelled || latestSnapshotRevisionRef.current > 0) return; void revealWindow(); void sendPropertiesReady(sessionId).catch(() => undefined); }, 500); // The main webview can restart independently of this child window. // Keep the registration alive so a fresh Properties bridge can send a // new snapshot and recover action state without replaying a request. readyHeartbeatTimer = window.setInterval(() => { if (cancelled) return; void sendPropertiesReady(sessionId).catch(() => undefined); }, 2000); void sendPropertiesReady(sessionId).catch(() => undefined); } catch (error) { if (!cancelled) setErrorMessage(errorText(error)); } }; void start(); return () => { cancelled = true; if (readyRetryTimerRef.current !== undefined) { window.clearInterval(readyRetryTimerRef.current); readyRetryTimerRef.current = undefined; } if (readyHeartbeatTimer !== undefined) window.clearInterval(readyHeartbeatTimer); unlistenSnapshot?.(); unlistenResult?.(); unlistenRemoved?.(); pendingActionRequestRef.current = null; pendingActionSendKeyRef.current = null; allowWindowCloseRef.current = false; appearanceCleanupRef.current?.(); appearanceCleanupRef.current = null; }; }, [closeCurrentWindow, currentWindow, hydrateDraft, revealWindow, sessionId, windowLabel]); useEffect(() => { if (!snapshot || draftTab !== null) return; hydrateDraft(snapshot); }, [draftTab, hydrateDraft, snapshot]); useEffect(() => { if (!downloadId || !snapshot || !isTorrent || !isTorrentDiagnosticsStatus(snapshot.status)) { setDetails(null); setFileProgress(null); setPeers(null); setAvailability(null); setDiagnosticError(''); setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); setPeerDiagnosticPhase('idle'); setAvailabilityDiagnosticPhase('idle'); return; } if (!isTorrentPollingStatus(snapshot.status)) { setFileProgress(null); setPeers(null); setAvailability(null); diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); setPeerDiagnosticPhase('idle'); setAvailabilityDiagnosticPhase('idle'); } void refreshDiagnostics(activeTab, downloadId); if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return; // Match the 1-second cadence of the normal Aria2 progress poll. The // diagnostics request itself is still single-flight, so a slow RPC cannot // create overlapping refreshes. const interval = window.setInterval(() => void refreshDiagnostics(activeTab, downloadId), 1000); return () => window.clearInterval(interval); }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]); useEffect(() => { let disposed = false; let unlisten: UnlistenFn | undefined; attachAsyncPropertiesListener(currentWindow.onCloseRequested(event => { if (allowWindowCloseRef.current) { allowWindowCloseRef.current = false; return; } if (!isDirtyRef.current) return; event.preventDefault(); setClosePrompt(true); }), () => disposed, value => { unlisten = value; }); return () => { disposed = true; unlisten?.(); }; }, [currentWindow]); const sendPendingAction = useCallback(async (reportError: boolean) => { const request = pendingActionRequestRef.current; if (!request || pendingActionRef.current === null) return; const requestKey = propertiesActionRequestKey(request); if (pendingActionSendKeyRef.current === requestKey) return; pendingActionSendKeyRef.current = requestKey; try { await sendPropertiesActionRequest(request); } catch (error) { // A rejected IPC promise does not prove that the host did not receive // the request. Keep the exact request pending so the idempotent host can // replay its result on the next attempt. if (reportError) setErrorMessage(errorText(error)); } finally { if (pendingActionSendKeyRef.current === requestKey) { pendingActionSendKeyRef.current = null; } } }, []); const requestAction = useCallback(async ( action: PropertiesAction, payload?: PropertiesActionRequest['payload'], ) => { if (!downloadId || pendingActionRef.current !== null) return; const requestId = nextPropertiesRequestId(requestIdRef.current); requestIdRef.current = requestId; pendingActionRef.current = action; setPendingAction(action); const request: PropertiesActionRequest = { windowLabel, downloadId, sessionId, requestId, action, payload, }; pendingActionRequestRef.current = request; await sendPendingAction(true); }, [downloadId, sendPendingAction, sessionId, windowLabel]); useEffect(() => { if (pendingAction === null) return; const retryTimer = window.setInterval(() => { void sendPendingAction(false); }, 2500); return () => window.clearInterval(retryTimer); }, [pendingAction, sendPendingAction]); const updateSecretDraft = (name: SecretName, value: string) => { setSecretDrafts(current => ({ ...current, [name]: { value, touched: true, clear: false }, })); setDraftTab('advanced'); }; const clearSecretDraft = (name: SecretName) => { setSecretDrafts(current => ({ ...current, [name]: { value: '', touched: true, clear: true }, })); setDraftTab('advanced'); }; const applyActiveTab = useCallback(async () => { if (!snapshot || !isEditableStatus(snapshot.status)) { closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; setErrorMessage(t($ => $.properties.editingUnavailable)); return; } const patch: PropertiesPatch = {}; if (activeTab === 'overview') { if (fileName !== snapshot.fileName) patch.fileName = fileName; const nextDestination = destination.trim() ? destination : undefined; const currentDestination = snapshot.destination?.trim() ? snapshot.destination : undefined; if (nextDestination !== currentDestination) patch.destination = encodePropertiesPatchValue(nextDestination); if (!isTorrent && connections.trim() && Number(connections) !== snapshot.connections) { patch.connections = Number(connections); } } else if (activeTab === 'files' && isTorrent) { const nextSelectedFiles = selectedFiles ?? fileProgress?.files.filter(file => file.selected).map(file => file.index) ?? []; if (!isTorrentFileSelectionEditable(snapshot.status)) { setErrorMessage(t($ => $.properties.editingUnavailable)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; return; } if (nextSelectedFiles.length === 0) { setErrorMessage(t($ => $.properties.torrentFileSelectionRequired)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; return; } await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles }); return; } else if (activeTab === 'trackers') { const nextTrackers = trackers.trim(); const currentTrackers = (snapshot.torrentTrackers ?? '').trim(); if (nextTrackers !== currentTrackers) patch.torrentTrackers = encodePropertiesPatchValue(nextTrackers || undefined); const nextExcludedTrackers = excludedTrackers.trim(); const currentExcludedTrackers = (snapshot.torrentExcludeTrackers ?? '').trim(); if (nextExcludedTrackers !== currentExcludedTrackers) patch.torrentExcludeTrackers = encodePropertiesPatchValue(nextExcludedTrackers || undefined); if (trackerConnectTimeout !== String(snapshot.torrentTrackerConnectTimeout ?? '')) { patch.torrentTrackerConnectTimeout = encodePropertiesPatchValue(trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined); } if (trackerTimeout !== String(snapshot.torrentTrackerTimeout ?? '')) { patch.torrentTrackerTimeout = encodePropertiesPatchValue(trackerTimeout.trim() ? Number(trackerTimeout) : undefined); } if (trackerInterval !== String(snapshot.torrentTrackerInterval ?? '')) { patch.torrentTrackerInterval = encodePropertiesPatchValue(trackerInterval.trim() ? Number(trackerInterval) : undefined); } } else if (activeTab === 'options' || activeTab === 'transfer') { if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = encodePropertiesPatchValue(downloadLimit.trim() ? downloadLimit : undefined); if (activeTab === 'transfer' && !isTorrent && connections.trim()) { const nextConnections = Number(connections); if (nextConnections !== snapshot.connections) patch.connections = nextConnections; } if (isTorrent) { if (removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0)) { setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; return; } if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = encodePropertiesPatchValue(uploadLimit.trim() ? uploadLimit : undefined); if (maxPeers !== String(snapshot.torrentMaxPeers ?? '')) { patch.torrentMaxPeers = encodePropertiesPatchValue(maxPeers.trim() ? Number(maxPeers) : undefined); } if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = encodePropertiesPatchValue(peerSpeedLimit.trim() ? peerSpeedLimit : undefined); if (seedTime !== String(snapshot.torrentSeedTime ?? '')) { patch.torrentSeedTime = encodePropertiesPatchValue(seedTime.trim() ? Number(seedTime) : undefined); } if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) { patch.torrentSeedRatio = encodePropertiesPatchValue(seedRatio.trim() ? Number(seedRatio) : undefined); } if (checkIntegrity !== (snapshot.torrentCheckIntegrity === true)) patch.torrentCheckIntegrity = checkIntegrity; if (removeUnselectedFile !== (snapshot.torrentRemoveUnselectedFile === true)) patch.torrentRemoveUnselectedFile = removeUnselectedFile; if (stopTimeout !== String(snapshot.torrentStopTimeout ?? '')) { patch.torrentStopTimeout = encodePropertiesPatchValue(stopTimeout.trim() ? Number(stopTimeout) : undefined); } if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = encodePropertiesPatchValue(prioritizePiece.trim() || undefined); const nextEncryptionPolicy = encryptionPolicy || undefined; if (nextEncryptionPolicy !== snapshot.torrentEncryptionPolicy) patch.torrentEncryptionPolicy = encodePropertiesPatchValue(nextEncryptionPolicy); const nextFileAllocation = fileAllocation || undefined; if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation); } } else if (activeTab === 'advanced') { for (const name of SECRET_NAMES) { const draft = secretDrafts[name]; if (!draft.touched) continue; patch[name] = draft.clear ? { kind: 'clear' } : { kind: 'replace', value: draft.value }; } } await requestAction('apply-properties', patch); }, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]); const chooseTab = (tab: PropertiesTab) => { if (tab === activeTab) { if (pendingTab !== null) setPendingTab(null); return false; } if (isDirty) { setPendingTab(tab); return false; } setActiveTab(tab); return true; }; const discardDraft = () => { if (pendingActionRef.current !== null) return; const shouldClose = closePrompt; if (snapshot) hydrateDraft(snapshot); draftTabRef.current = null; setDraftTab(null); if (pendingTab) setActiveTab(pendingTab); setPendingTab(null); setClosePrompt(false); if (shouldClose) void closeCurrentWindow(true); }; const closeWindow = async () => { if (isDirtyRef.current) { setClosePrompt(true); return; } await closeCurrentWindow(); }; const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => { if (!downloadId) return; if (action === 'verify') { await requestAction('verify-torrent'); return; } if (pendingTorrentCommand !== null) return; setPendingTorrentCommand(action); try { if (action === 'magnet') { await writeClipboardText(await invoke('get_torrent_magnet_link', { id: downloadId })); setNotice(t($ => $.properties.torrentMagnetCopied)); } else if (action === 'export') { const destinationPath = await save({ defaultPath: `${snapshot?.fileName || 'download'}.torrent` }); if (destinationPath) { await invoke('export_torrent_metadata', { id: downloadId, destination: destinationPath }); setNotice(t($ => $.properties.torrentMetadataExported)); } } else if (action === 'move') { const selected = await open({ directory: true, multiple: false }); if (selected && typeof selected === 'string') { await invoke('move_torrent_data', { id: downloadId, destination: selected }); setNotice(t($ => $.properties.torrentMoveCompleted)); } } } catch (error) { setErrorMessage(errorText(error)); } finally { setPendingTorrentCommand(null); } }; const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current; const windowControlRailWidth = getWindowControlRailWidth(windowChrome.controlStyle); const windowShellClassName = `properties-window-shell properties-window-shell--controls-${windowChrome.side} properties-window-shell--style-${windowChrome.controlStyle} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`; const windowShellStyle = { '--properties-window-control-rail-width': `${windowControlRailWidth}px` } as CSSProperties; if (!downloadId || !snapshot) { return (
{t($ => $.downloadTable.properties)} - Firelink
{errorMessage || t($ => $.app.loading)}
); } const progress = resolveDownloadFraction({ fraction: snapshot.fraction, downloadedBytes: snapshot.downloadedBytes, totalBytes: snapshot.totalBytes, totalIsEstimate: snapshot.totalIsEstimate, isMedia: snapshot.isMedia, size: snapshot.size, status: snapshot.status, }); const lifecycleAction = getPropertiesLifecycleAction(snapshot.status); const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status); const footerActions = getPropertiesFooterActions({ isDirty, hasUnsavedNavigation: pendingTab !== null || closePrompt, }); const isPromptFooter = footerActions.includes('keepEditing'); const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status); const total = snapshot.size || (snapshot.totalBytes === undefined ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); const statusLabel = t($ => $.downloads.status[snapshot.status]); const connectionMetric = isTorrent ? String(snapshot.connectedPeers ?? '—') : snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'} ${t($ => $.properties.connections)}`; const queuePlacement = formatPropertiesQueuePlacement( snapshot.queueName, snapshot.queuePosition, position => t($ => $.properties.queuePosition, { position }), ); const progressPercent = `${Math.round(progress * 100)}%`; const statusTone = propertiesStatusTone(snapshot.status); const lifecycleLabel = lifecycleAction === 'pause' ? t($ => $.downloads.actions.pause) : lifecycleAction === 'resume' ? t($ => $.downloads.actions.resume) : lifecycleAction === 'retry' ? t($ => $.downloads.actions.retry) : t($ => $.downloads.actions.start); const tabLabel = (tab: PropertiesTab) => { switch (tab) { case 'overview': return t($ => $.properties.tabs.overview); case 'files': return t($ => $.properties.tabs.files); case 'trackers': return t($ => $.properties.tabs.trackers); case 'peers': return t($ => $.properties.tabs.peers); case 'options': return t($ => $.properties.tabs.options); case 'transfer': return t($ => $.properties.tabs.transfer); case 'advanced': return t($ => $.properties.tabs.advanced); } }; return (
{snapshot.fileName} - {t($ => $.downloadTable.properties)} - Firelink

{snapshot.fileName}

{statusLabel}

{queuePlacement}

$.actions.continue)}> {lifecycleAction && } {isTorrent && <>
$.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}>
}
$.properties.progress)} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}>
{progressPercent}
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
{t($ => $.properties.speed)}{snapshot.speed || '—'}
{t($ => $.properties.eta)}{snapshot.eta || '—'}
{isTorrent ? t($ => $.properties.torrentConnectedPeers) : t($ => $.properties.connections)}{connectionMetric}
{isTorrent && <>
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
}
{snapshot.destination || '—'}
{tabLabel(activeTab)}
{activeTab === 'overview' &&
{t($ => $.properties.url)} {urlCanExpand && }

{snapshot.url}

{t($ => $.properties.category)}

{snapshot.category}

{t($ => $.properties.dateAdded)}{snapshot.dateAdded || '—'} {t($ => $.properties.lastTry)}{snapshot.lastTry || '—'} {t($ => $.properties.queueId)}{queuePlacement} {t($ => $.properties.resumable)}{snapshot.resumable === false ? '—' : '✓'} {snapshot.lastError && <> {t($ => $.properties.lastError)}
{snapshot.lastErrorKind === 'nameResolution' && (

{snapshot.status === 'retrying' ? snapshot.lastResolverFallback === true ? t($ => $.downloads.errors.nameResolutionRetrying) : t($ => $.downloads.status.retrying) : t($ => $.downloads.errors.nameResolutionFailed)}

)} {snapshot.lastError}
}
{snapshot.isMedia === true &&
{t($ => $.addDownloads.format)}{snapshot.mediaFormatSelector || '—'} {t($ => $.addDownloads.quality)}{snapshot.mediaQuality || '—'} {t($ => $.properties.configuredConcurrency)}{snapshot.connections ?? '—'}
} {isTorrent && details &&
{t($ => $.properties.torrentDetailsDisplayName)}{details.displayName || '—'} {t($ => $.properties.torrentDetailsInfoHash)}{details.infoHash} {t($ => $.properties.torrentDetailsSize)}{formatDownloadBytes(details.totalBytes)} {t($ => $.properties.torrentDetailsFiles)}{details.fileCount} {t($ => $.properties.torrentDetailsPieces)}{details.pieceCount} × {formatDownloadBytes(details.pieceLength)} {t($ => $.properties.torrentDetailsPrivate)}{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)} {t($ => $.properties.torrentDetailsCreated)}{details.creationDate || '—'} {t($ => $.properties.torrentDetailsCreator)}{details.creator || '—'} {t($ => $.properties.torrentDetailsComment)}{details.comment || '—'}
} {isTorrent &&
}
} {activeTab === 'files' && isTorrent &&
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index + 1} ${file.relativePath}`} />{file.index + 1}{file.relativePath}{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress &&

{t($ => $.properties.torrentFileProgressLoading)}

} {diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError &&

{t($ => $.properties.torrentFileProgressUnavailable)}

} {diagnosticError &&

{diagnosticError}

}
} {activeTab === 'trackers' && isTorrent &&