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 (
);
};
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
{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 &&
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress &&
{t($ => $.properties.torrentFileProgressLoading)}
}
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError &&
{t($ => $.properties.torrentFileProgressUnavailable)}
}
{diagnosticError &&
{diagnosticError}
}
}
{activeTab === 'trackers' && isTorrent && }
{activeTab === 'peers' && isTorrent &&
{t($ => $.properties.torrentPeerDiagnostics)}
{peers
? t($ => $.properties.torrentPeerCount, {
total: formatPropertiesDiagnosticCount(peers.totalPeers, snapshot.appearance.locale),
seeders: formatPropertiesDiagnosticCount(peers.totalSeeders, snapshot.appearance.locale),
})
: diagnosticsLoading
? t($ => $.properties.torrentPeerDiagnosticsLoading)
: t($ => $.properties.torrentPeerDiagnosticsUnavailable)}
{t($ => $.properties.torrentPeerDiagnosticsHint)}
{peerDiagnosticPhase === 'stale' &&
{t($ => $.properties.torrentPeerDiagnosticsStale)}
}
{peers?.truncated &&
{t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers })}
}
{t($ => $.properties.torrentAvailability)}
{availability ? `${formatPropertiesAvailability(availability.availability, snapshot.appearance.locale)} — ${formatPropertiesDiagnosticCount(availability.pieceCount, snapshot.appearance.locale)} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}
{availabilityDiagnosticPhase === 'stale' &&
{t($ => $.properties.torrentPeerDiagnosticsStale)}
}
| {t($ => $.properties.torrentPeerAddress)} | {t($ => $.properties.torrentPeerDownload)} | {t($ => $.properties.torrentPeerUpload)} | {t($ => $.properties.torrentPeerSeeder)} | {t($ => $.properties.torrentPeerChoking)} |
{peers?.peers.map((peer, index) => | {peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'} | {formatDownloadBytes(peer.downloadSpeed)}/s | {formatDownloadBytes(peer.uploadSpeed)}/s | {peer.seeder ? '✓' : '—'} | {peer.peerChoking ? '✓' : '—'} |
)}
{diagnosticError &&
{diagnosticError}
}
}
{activeTab === 'transfer' && }
{activeTab === 'options' && isTorrent &&
{t($ => $.properties.tabs.options)}
{t($ => $.properties.torrentPeerOptionsSavedHint)}
{t($ => $.properties.blankUsesDefault)}
{t($ => $.properties.liveTorrentPeerOptions)}
{t($ => $.properties.speedLimitHint)}
{t($ => $.addDownloads.torrentSeeding)}
{t($ => $.addDownloads.seedRatioHint)}
{t($ => $.properties.torrentOptionsBehavior)}
{t($ => $.properties.torrentOptionsBehaviorHint)}
$.properties.torrentVerifyIntegrity)}
hint={t($ => $.properties.torrentVerifyIntegrityHint)}
checked={checkIntegrity}
onChange={checked => { setCheckIntegrity(checked); setDraftTab('options'); }}
disabled={!editingEnabled}
/>
$.properties.torrentRemoveUnselectedFile)}
hint={t($ => $.properties.torrentRemoveUnselectedFileHint)}
checked={removeUnselectedFile}
onChange={checked => { setRemoveUnselectedFile(checked); setDraftTab('options'); }}
disabled={!editingEnabled || (!removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0))}
/>
$.properties.torrentFileAllocation)}
controlId="properties-options-file-allocation"
hint={t($ => $.properties.torrentFileAllocationHint)}
meta={fileAllocation === '' ? t($ => $.properties.usingDefault) : t($ => $.properties.customPerDownload)}
>
$.properties.torrentEncryptionPolicy)}
controlId="properties-options-encryption-policy"
hint={t($ => $.properties.torrentEncryptionPolicyHint)}
meta={encryptionPolicy === '' ? t($ => $.properties.usingDefault) : t($ => $.properties.customPerDownload)}
>
}
{activeTab === 'advanced' &&
{t($ => $.properties.advancedTransfer)}
{t($ => $.properties.connections)}{snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}
{t($ => $.properties.speedCap)}{snapshot.speedLimit || '—'}
{t($ => $.properties.username)}{snapshot.hasUsername ? '✓' : '—'}
{t($ => $.properties.password)}{snapshot.hasPassword ? '✓' : '—'}
{t($ => $.properties.cookies)}{snapshot.hasCookies ? '✓' : '—'}
{t($ => $.properties.headers)}{snapshot.hasHeaders ? '✓' : '—'}
{(['username', 'password'] as const).map(name =>
)}
{(['cookies', 'headers'] as const).map(name =>
)}
{t($ => $.properties.transferSettings)}
}
{(isDirty || errorMessage || notice || pendingTab || closePrompt) &&
{isPromptFooter ?
{t($ => $.scheduler.unsavedChanges)} :
{errorMessage || notice}{footerActions.includes('discardChanges') && <>>}
}
}
);
};