From e2654510aff0f9c484e0e00570f26629709d2a1b Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 6 Aug 2026 23:58:06 +0330 Subject: [PATCH] fix(ui): clarify download properties inputs --- src/components/PropertiesWindowApp.tsx | 297 +++++++++++++++--- src/components/PropertiesWindowBridgeHost.tsx | 9 + src/i18n/catalogs/en.ts | 20 ++ src/i18n/catalogs/fa.ts | 20 ++ src/i18n/catalogs/he.ts | 20 ++ src/i18n/catalogs/ru.ts | 20 ++ src/i18n/catalogs/uk.ts | 20 ++ src/i18n/catalogs/zh-CN.ts | 20 ++ src/i18n/resources.test.ts | 1 + src/index.css | 253 ++++++++++++++- src/propertiesBridge.test.ts | 13 + src/propertiesBridge.ts | 37 ++- 12 files changed, 679 insertions(+), 51 deletions(-) diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 30169e7..b8c95d7 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +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'; @@ -16,9 +16,9 @@ import { PROPERTIES_WINDOW_SNAPSHOT, attachAsyncPropertiesListener, DEFAULT_PROPERTIES_WINDOW_CHROME, + encodePropertiesPatchValue, formatPropertiesQueuePlacement, getPropertiesLifecycleAction, - propertiesTorrentPeerLimit, propertiesDiagnosticRequestState, sendPropertiesActionRequest, sendPropertiesReady, @@ -73,6 +73,68 @@ const safeTitle = (name: string) => { 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'; @@ -156,8 +218,8 @@ export const PropertiesWindowApp = () => { const [removeUnselectedFile, setRemoveUnselectedFile] = useState(false); const [stopTimeout, setStopTimeout] = useState(''); const [prioritizePiece, setPrioritizePiece] = useState(''); - const [encryptionPolicy, setEncryptionPolicy] = useState(TORRENT_ENCRYPTION_POLICY_DISABLED); - const [fileAllocation, setFileAllocation] = useState('prealloc'); + const [encryptionPolicy, setEncryptionPolicy] = useState(''); + const [fileAllocation, setFileAllocation] = useState(''); const [trackerConnectTimeout, setTrackerConnectTimeout] = useState(''); const [trackerTimeout, setTrackerTimeout] = useState(''); const [trackerInterval, setTrackerInterval] = useState(''); @@ -335,8 +397,8 @@ export const PropertiesWindowApp = () => { setRemoveUnselectedFile(next.torrentRemoveUnselectedFile === true); setStopTimeout(next.torrentStopTimeout === undefined ? '' : String(next.torrentStopTimeout)); setPrioritizePiece(next.torrentPrioritizePiece ?? ''); - setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED); - setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc'); + 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)); @@ -773,7 +835,9 @@ export const PropertiesWindowApp = () => { const patch: PropertiesPatch = {}; if (activeTab === 'overview') { if (fileName !== snapshot.fileName) patch.fileName = fileName; - if (destination !== (snapshot.destination ?? '')) patch.destination = destination || undefined; + 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); } @@ -796,19 +860,23 @@ export const PropertiesWindowApp = () => { await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles }); return; } else if (activeTab === 'trackers') { - if (trackers !== (snapshot.torrentTrackers ?? '')) patch.torrentTrackers = trackers; - if (excludedTrackers !== (snapshot.torrentExcludeTrackers ?? '')) patch.torrentExcludeTrackers = excludedTrackers; + 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 = trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined; + patch.torrentTrackerConnectTimeout = encodePropertiesPatchValue(trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined); } if (trackerTimeout !== String(snapshot.torrentTrackerTimeout ?? '')) { - patch.torrentTrackerTimeout = trackerTimeout.trim() ? Number(trackerTimeout) : undefined; + patch.torrentTrackerTimeout = encodePropertiesPatchValue(trackerTimeout.trim() ? Number(trackerTimeout) : undefined); } if (trackerInterval !== String(snapshot.torrentTrackerInterval ?? '')) { - patch.torrentTrackerInterval = trackerInterval.trim() ? Number(trackerInterval) : undefined; + patch.torrentTrackerInterval = encodePropertiesPatchValue(trackerInterval.trim() ? Number(trackerInterval) : undefined); } } else if (activeTab === 'options' || activeTab === 'transfer') { - if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = downloadLimit; + 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; @@ -820,28 +888,27 @@ export const PropertiesWindowApp = () => { switchAfterSaveRef.current = null; return; } - if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = uploadLimit; + if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = encodePropertiesPatchValue(uploadLimit.trim() ? uploadLimit : undefined); if (maxPeers !== String(snapshot.torrentMaxPeers ?? '')) { - patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined; + patch.torrentMaxPeers = encodePropertiesPatchValue(maxPeers.trim() ? Number(maxPeers) : undefined); } - if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = peerSpeedLimit; + if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = encodePropertiesPatchValue(peerSpeedLimit.trim() ? peerSpeedLimit : undefined); if (seedTime !== String(snapshot.torrentSeedTime ?? '')) { - patch.torrentSeedTime = seedTime.trim() ? Number(seedTime) : undefined; + patch.torrentSeedTime = encodePropertiesPatchValue(seedTime.trim() ? Number(seedTime) : undefined); } if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) { - patch.torrentSeedRatio = seedRatio.trim() ? Number(seedRatio) : undefined; + 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 = stopTimeout.trim() ? Number(stopTimeout) : undefined; + patch.torrentStopTimeout = encodePropertiesPatchValue(stopTimeout.trim() ? Number(stopTimeout) : undefined); } - if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = prioritizePiece.trim() || undefined; - const snapshotEncryptionPolicy = (snapshot.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED; - if (encryptionPolicy !== snapshotEncryptionPolicy) { - patch.torrentEncryptionPolicy = encryptionPolicy === TORRENT_ENCRYPTION_POLICY_DISABLED ? undefined : encryptionPolicy; - } - if (fileAllocation !== ((snapshot.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc')) patch.torrentFileAllocation = fileAllocation; + 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) { @@ -994,7 +1061,7 @@ export const PropertiesWindowApp = () => {
{snapshot.fileName} - {t($ => $.downloadTable.properties)} - Firelink
-
+
@@ -1201,31 +1268,173 @@ export const PropertiesWindowApp = () => { {activeTab === 'transfer' &&
- + $.properties.speedCap)} + controlId="properties-transfer-speed-cap" + hint={t($ => $.properties.speedLimitHint)} + meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} + > + { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> +
{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}

{snapshot.isMedia === true ? snapshot.connections ?? '—' : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}

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

} - {activeTab === 'options' && isTorrent &&
-
- - - - - - - - + {activeTab === 'options' && isTorrent &&
+
+
+

{t($ => $.properties.tabs.options)}

+

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

+
+ {t($ => $.properties.blankUsesDefault)}
-
- - - - -
-

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

+ +
+
+
+

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

+

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

+
+
+
+ $.properties.speedCap)} + controlId="properties-options-speed-cap" + hint={t($ => $.properties.speedLimitHint)} + meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} + > + { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> + + $.properties.liveTorrentUploadLimit)} + controlId="properties-options-upload-limit" + hint={t($ => $.properties.liveTorrentUploadLimitHint)} + meta={uploadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} + > + { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> + + $.properties.torrentMaxPeers)} + controlId="properties-options-max-peers" + hint={t($ => $.properties.torrentPeerOptionsSavedHint)} + meta={maxPeers.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatMaxPeers) })} + > + { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleMaxPeers)} disabled={!editingEnabled} /> + + $.properties.torrentPeerSpeedLimit)} + controlId="properties-options-peer-speed-limit" + hint={t($ => $.properties.torrentPeerOptionsSavedHint)} + meta={peerSpeedLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} + > + { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> + +
+
+ +
+
+
+

{t($ => $.addDownloads.torrentSeeding)}

+

{t($ => $.addDownloads.seedRatioHint)}

+
+
+
+ $.addDownloads.seedTime)} + controlId="properties-options-seed-time" + hint={t($ => $.properties.torrentSeedTimeHint)} + meta={seedTime.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSeedTime) })} + > + { setSeedTime(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.inputExampleSeedTime)} disabled={!editingEnabled} /> + + $.addDownloads.seedRatio)} + controlId="properties-options-seed-ratio" + hint={t($ => $.addDownloads.seedRatioHint)} + meta={seedRatio.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSeedRatio) })} + > + { setSeedRatio(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.inputExampleSeedRatio)} disabled={!editingEnabled} /> + + $.properties.torrentStopTimeout)} + controlId="properties-options-stop-timeout" + hint={t($ => $.properties.torrentStopTimeoutHint)} + meta={stopTimeout.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatStopTimeout) })} + > + { setStopTimeout(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleStopTimeout)} disabled={!editingEnabled} /> + + $.properties.torrentPrioritizePiece)} + controlId="properties-options-prioritize-piece" + hint={t($ => $.properties.torrentPrioritizePieceHint)} + meta={prioritizePiece.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} + format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatPiecePriority) })} + > + { setPrioritizePiece(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExamplePiecePriority)} disabled={!editingEnabled} /> + +
+
+ +
+
+
+

{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' &&
diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 23641f4..12242f9 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -25,9 +25,11 @@ import { beginExclusivePropertiesAction, classifyPropertiesActionRequest, createFrameCoalescer, + decodePropertiesPatchValue, enqueuePropertiesAction, getPropertiesLifecycleAction, propertiesActionRequestKey, + PROPERTIES_PATCH_CLEARABLE_KEYS, sanitizePropertiesSnapshot, sendPropertiesActionResult, sendPropertiesRemoved, @@ -86,6 +88,13 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial => 'torrentFileAllocation', ] as const) copy(key); + for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) { + if (Object.prototype.hasOwnProperty.call(rawPatch, key)) { + const value = (rawPatch as Record)[key]; + (safePatch as Record)[key] = decodePropertiesPatchValue(value); + } + } + if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') { throw new Error('Invalid file name'); } diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 9260a62..6612da5 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -247,6 +247,20 @@ const common = { connectionCountUnknown: '—/{{total}}', connectionsUnavailable: '—', speedCap: 'Speed cap', + inputFormat: 'Format: {{format}}', + inputFormatSpeedLimit: '512K, 2M, or 1G', + inputFormatMaxPeers: '0–1000; 0 means unlimited', + inputFormatSeedTime: 'minutes, e.g. 60', + inputFormatSeedRatio: 'decimal, e.g. 1.5; 0 means time-only', + inputFormatStopTimeout: 'whole seconds; 0 disables it', + inputFormatPiecePriority: 'head=1M,tail=1M', + inputExampleSpeedLimit: 'e.g. 512K', + inputExampleMaxPeers: 'e.g. 55', + inputExampleSeedTime: 'e.g. 60', + inputExampleSeedRatio: 'e.g. 1.5', + inputExampleStopTimeout: 'e.g. 300', + inputExamplePiecePriority: 'e.g. head=1M,tail=1M', + speedLimitHint: 'Leave blank to use the global default; enter a value to set a cap for this download.', liveSpeedLimit: 'Live speed cap', liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.', liveSpeedLimitPlaceholder: 'e.g. 1024K', @@ -336,6 +350,7 @@ const common = { torrentUploaded: 'Uploaded', torrentRatio: 'Ratio', torrentSeededDuration: 'Seeded', + torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.', torrentConnectedPeers: 'Peers', torrentSeeders: 'Seeders', torrentUploadSpeed: 'Upload speed', @@ -354,6 +369,8 @@ const common = { torrentFileAllocationPrealloc: 'Preallocate files', torrentFileAllocationNone: 'Allocate as needed', torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.', + torrentOptionsBehavior: 'Torrent behavior', + torrentOptionsBehaviorHint: 'Controls verification, storage allocation, encryption, and cleanup for this Torrent.', torrentDetails: 'Torrent details', torrentCopyMagnet: 'Copy magnet link', torrentExportMetadata: 'Export .torrent', @@ -412,6 +429,9 @@ const common = { defaultValue: ' (default)', savedTooltip: 'Saved for this download; Settings changes apply to new downloads.', defaultTooltip: 'Using the current default for new downloads.', + blankUsesDefault: 'Leave blank · use default', + usingDefault: 'Using default', + customPerDownload: 'Custom for this download', identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.', transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.', download: 'Download', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 3b2df1d..52a806a 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -247,6 +247,20 @@ const fa = { connectionCountUnknown: '—/{{total}} فعال', connectionsUnavailable: '—', speedCap: 'سقف سرعت', + inputFormat: 'قالب: {{format}}', + inputFormatSpeedLimit: '512K، 2M یا 1G', + inputFormatMaxPeers: '0 تا 1000؛ 0 یعنی نامحدود', + inputFormatSeedTime: 'دقیقه، مثلاً 60', + inputFormatSeedRatio: 'عدد اعشاری، مثلاً 1.5؛ 0 یعنی فقط بر اساس زمان', + inputFormatStopTimeout: 'تعداد ثانیهٔ کامل؛ 0 غیرفعال می‌کند', + inputFormatPiecePriority: 'head=1M,tail=1M', + inputExampleSpeedLimit: 'مثلاً 512K', + inputExampleMaxPeers: 'مثلاً 55', + inputExampleSeedTime: 'مثلاً 60', + inputExampleSeedRatio: 'مثلاً 1.5', + inputExampleStopTimeout: 'مثلاً 300', + inputExamplePiecePriority: 'مثلاً head=1M,tail=1M', + speedLimitHint: 'برای استفاده از مقدار سراسری خالی بگذارید؛ برای این دانلود یک سقف مشخص وارد کنید.', liveSpeedLimit: 'سقف سرعت زنده', liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال می‌شود. سرعت دانلودهای رسانه‌ای هنگام اجرا قابل تغییر نیست.', liveSpeedLimitPlaceholder: 'مثلاً 1024K', @@ -336,6 +350,7 @@ const fa = { torrentUploaded: 'آپلودشده', torrentRatio: 'نسبت', torrentSeededDuration: 'مدت سید', + torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه می‌دهد. برای استفاده از پیش‌فرض خالی بگذارید.', torrentConnectedPeers: 'همتاها', torrentSeeders: 'سیدها', torrentUploadSpeed: 'سرعت آپلود', @@ -354,6 +369,8 @@ const fa = { torrentFileAllocationPrealloc: 'تخصیص از پیش', torrentFileAllocationNone: 'تخصیص هنگام نیاز', torrentFileAllocationHint: 'تخصیص از پیش فضای فایل‌های انتخاب‌شده را قبل از انتقال رزرو می‌کند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمی‌دهد.', + torrentOptionsBehavior: 'رفتار تورنت', + torrentOptionsBehaviorHint: 'بررسی صحت، تخصیص فضا، رمزنگاری و پاک‌سازی این تورنت را کنترل می‌کند.', torrentDetails: 'جزئیات تورنت', torrentCopyMagnet: 'کپی پیوند مگنت', torrentExportMetadata: 'خروجی .torrent', @@ -412,6 +429,9 @@ const fa = { defaultValue: ' (پیش‌فرض)', savedTooltip: 'برای این دانلود ذخیره‌شده است؛ تغییرات تنظیمات روی دانلودهای جدید اعمال می‌شود.', defaultTooltip: 'استفاده از پیش‌فرض کنونی برای دانلودهای جدید.', + blankUsesDefault: 'خالی = استفاده از پیش‌فرض', + usingDefault: 'استفاده از پیش‌فرض', + customPerDownload: 'سفارشی برای این دانلود', identityReadOnly: 'هویت فایل فقط‌خواندنی است. تنظیمات انتقال برای دانلود مجدد ذخیره می‌شوند.', transferSettings: 'تنظیمات انتقال را می‌توان پس از توقف تغییر داد. انتقال‌های کنونی گزینه‌های فعلی خود را حفظ می‌کنند.', download: 'دانلود', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 525ab75..306c4ec 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -247,6 +247,20 @@ const he = { connectionCountUnknown: '—/{{total}} פעילות', connectionsUnavailable: '—', speedCap: 'הגבלת מהירות', + inputFormat: 'תבנית: {{format}}', + inputFormatSpeedLimit: '512K, 2M או 1G', + inputFormatMaxPeers: '0–1000; ‏0 פירושו ללא הגבלה', + inputFormatSeedTime: 'בדקות, למשל 60', + inputFormatSeedRatio: 'מספר עשרוני, למשל 1.5; ‏0 פירושו לפי זמן בלבד', + inputFormatStopTimeout: 'שניות שלמות; ‏0 משבית', + inputFormatPiecePriority: 'head=1M,tail=1M', + inputExampleSpeedLimit: 'לדוגמה 512K', + inputExampleMaxPeers: 'לדוגמה 55', + inputExampleSeedTime: 'לדוגמה 60', + inputExampleSeedRatio: 'לדוגמה 1.5', + inputExampleStopTimeout: 'לדוגמה 300', + inputExamplePiecePriority: 'לדוגמה head=1M,tail=1M', + speedLimitHint: 'השאר ריק כדי להשתמש בערך הגלובלי, או הזן הגבלה עבור הורדה זו.', liveSpeedLimit: 'הגבלת מהירות בזמן אמת', liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.', liveSpeedLimitPlaceholder: 'לדוגמה 1024K', @@ -336,6 +350,7 @@ const he = { torrentUploaded: 'הועלה', torrentRatio: 'יחס', torrentSeededDuration: 'משך שיתוף', + torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.', torrentConnectedPeers: 'עמיתים', torrentSeeders: 'משתפים', torrentUploadSpeed: 'מהירות העלאה', @@ -354,6 +369,8 @@ const he = { torrentFileAllocationPrealloc: 'הקצאה מראש', torrentFileAllocationNone: 'הקצאה לפי הצורך', torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.', + torrentOptionsBehavior: 'התנהגות Torrent', + torrentOptionsBehaviorHint: 'שולט באימות, הקצאת האחסון, ההצפנה והניקוי של Torrent זה.', torrentDetails: 'פרטי טורנט', torrentCopyMagnet: 'העתקת קישור מגנט', torrentExportMetadata: 'ייצוא ‎.torrent', @@ -412,6 +429,9 @@ const he = { defaultValue: ' (ברירת מחדל)', savedTooltip: 'נשמר עבור הורדה זו; שינויים בהגדרות יחולו על הורדות חדשות.', defaultTooltip: 'שימוש בברירת המחדל הנוכחית להורדות חדשות.', + blankUsesDefault: 'ריק · שימוש בברירת המחדל', + usingDefault: 'ברירת מחדל', + customPerDownload: 'מותאם להורדה זו', identityReadOnly: 'זהות הקובץ היא לקריאה בלבד. הגדרות ההעברה נשמרות להורדה מחדש.', transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.', download: 'הורדה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 763ca7f..eef79cb 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -247,6 +247,20 @@ const ru = { connectionCountUnknown: '—/{{total}} активных', connectionsUnavailable: '—', speedCap: 'Ограничение скорости', + inputFormat: 'Формат: {{format}}', + inputFormatSpeedLimit: '512K, 2M или 1G', + inputFormatMaxPeers: '0–1000; 0 — без ограничений', + inputFormatSeedTime: 'минуты, например 60', + inputFormatSeedRatio: 'десятичное число, например 1.5; 0 — только по времени', + inputFormatStopTimeout: 'целые секунды; 0 отключает', + inputFormatPiecePriority: 'head=1M,tail=1M', + inputExampleSpeedLimit: 'например, 512K', + inputExampleMaxPeers: 'например, 55', + inputExampleSeedTime: 'например, 60', + inputExampleSeedRatio: 'например, 1.5', + inputExampleStopTimeout: 'например, 300', + inputExamplePiecePriority: 'например, head=1M,tail=1M', + speedLimitHint: 'Оставьте пустым для глобального значения по умолчанию или задайте ограничение для этой загрузки.', liveSpeedLimit: 'Текущее ограничение скорости', liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.', liveSpeedLimitPlaceholder: 'например, 1024K', @@ -336,6 +350,7 @@ const ru = { torrentUploaded: 'Отдано', torrentRatio: 'Коэффициент', torrentSeededDuration: 'Время раздачи', + torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.', torrentConnectedPeers: 'Пиры', torrentSeeders: 'Сиды', torrentUploadSpeed: 'Скорость отдачи', @@ -354,6 +369,8 @@ const ru = { torrentFileAllocationPrealloc: 'Предварительное выделение', torrentFileAllocationNone: 'Выделять по мере необходимости', torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.', + torrentOptionsBehavior: 'Поведение Torrent', + torrentOptionsBehaviorHint: 'Управляет проверкой, выделением места, шифрованием и очисткой этого Torrent.', torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.', torrentDetails: 'Сведения о Torrent', torrentCopyMagnet: 'Копировать magnet-ссылку', @@ -412,6 +429,9 @@ const ru = { defaultValue: ' (по умолчанию)', savedTooltip: 'Сохранено для этой загрузки. Изменения в Настройках будут применяться к новым загрузкам.', defaultTooltip: 'Используется текущее значение по умолчанию для новых загрузок.', + blankUsesDefault: 'Пусто · значение по умолчанию', + usingDefault: 'По умолчанию', + customPerDownload: 'Для этой загрузки', identityReadOnly: 'Идентификация файла доступна только для чтения. Настройки передачи сохранены для повторного скачивания.', transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.', download: 'Загрузка', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 9c8e729..ac6308f 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -247,6 +247,20 @@ const uk = { connectionCountUnknown: '—/{{total}} активних', connectionsUnavailable: '—', speedCap: 'Обмеження швидкості', + inputFormat: 'Формат: {{format}}', + inputFormatSpeedLimit: '512K, 2M або 1G', + inputFormatMaxPeers: '0–1000; 0 означає без обмежень', + inputFormatSeedTime: 'хвилини, наприклад 60', + inputFormatSeedRatio: 'десяткове число, наприклад 1.5; 0 — лише за часом', + inputFormatStopTimeout: 'цілі секунди; 0 вимикає', + inputFormatPiecePriority: 'head=1M,tail=1M', + inputExampleSpeedLimit: 'наприклад, 512K', + inputExampleMaxPeers: 'наприклад, 55', + inputExampleSeedTime: 'наприклад, 60', + inputExampleSeedRatio: 'наприклад, 1.5', + inputExampleStopTimeout: 'наприклад, 300', + inputExamplePiecePriority: 'наприклад, head=1M,tail=1M', + speedLimitHint: 'Залиште порожнім для глобального значення за замовчуванням або задайте обмеження для цього завантаження.', liveSpeedLimit: 'Поточне обмеження швидкості', liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.', liveSpeedLimitPlaceholder: 'наприклад, 1024K', @@ -336,6 +350,7 @@ const uk = { torrentUploaded: 'Віддано', torrentRatio: 'Коефіцієнт', torrentSeededDuration: 'Час роздачі', + torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.', torrentConnectedPeers: 'Піри', torrentSeeders: 'Сіди', torrentUploadSpeed: 'Швидкість віддачі', @@ -354,6 +369,8 @@ const uk = { torrentFileAllocationPrealloc: 'Попереднє виділення', torrentFileAllocationNone: 'Виділяти за потреби', torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.', + torrentOptionsBehavior: 'Поведінка Torrent', + torrentOptionsBehaviorHint: 'Керує перевіркою, виділенням місця, шифруванням і очищенням цього Torrent.', torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.', torrentDetails: 'Відомості про Torrent', torrentCopyMagnet: 'Копіювати magnet-посилання', @@ -412,6 +429,9 @@ const uk = { defaultValue: ' (за замовчуванням)', savedTooltip: 'Збережено для цього завантаження; зміни в налаштуваннях застосовуються до нових завантажень.', defaultTooltip: 'Використовується поточне значення за замовчуванням для нових завантажень.', + blankUsesDefault: 'Порожньо · значення за замовчуванням', + usingDefault: 'За замовчуванням', + customPerDownload: 'Для цього завантаження', identityReadOnly: 'Ідентифікатор файлу доступний лише для читання. Налаштування передачі збережено для повторного завантаження.', transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.', download: 'Завантаження', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 7233271..25c145a 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -247,6 +247,20 @@ const zhCN = { connectionCountUnknown: '—/{{total}} 个连接', connectionsUnavailable: '—', speedCap: '速度上限', + inputFormat: '格式:{{format}}', + inputFormatSpeedLimit: '512K、2M 或 1G', + inputFormatMaxPeers: '0–1000;0 表示不限', + inputFormatSeedTime: '分钟,例如 60', + inputFormatSeedRatio: '小数,例如 1.5;0 表示仅按时间', + inputFormatStopTimeout: '整数秒;0 表示禁用', + inputFormatPiecePriority: 'head=1M,tail=1M', + inputExampleSpeedLimit: '例如 512K', + inputExampleMaxPeers: '例如 55', + inputExampleSeedTime: '例如 60', + inputExampleSeedRatio: '例如 1.5', + inputExampleStopTimeout: '例如 300', + inputExamplePiecePriority: '例如 head=1M,tail=1M', + speedLimitHint: '留空以使用全局默认值;输入数值可为此下载设置上限。', liveSpeedLimit: '实时速度上限', liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。', liveSpeedLimitPlaceholder: '例如 1024K', @@ -336,6 +350,7 @@ const zhCN = { torrentUploaded: '已上传', torrentRatio: '分享率', torrentSeededDuration: '做种时长', + torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。', torrentConnectedPeers: '连接数', torrentSeeders: '种子数', torrentUploadSpeed: '上传速度', @@ -354,6 +369,8 @@ const zhCN = { torrentFileAllocationPrealloc: '预分配文件', torrentFileAllocationNone: '按需分配', torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。', + torrentOptionsBehavior: 'Torrent 行为', + torrentOptionsBehaviorHint: '控制此 Torrent 的校验、存储分配、加密和清理。', torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。', torrentDetails: 'Torrent 详细信息', torrentCopyMagnet: '复制磁力链接', @@ -412,6 +429,9 @@ const zhCN = { defaultValue: ' (默认)', savedTooltip: '已为此下载保存;设置中的更改将应用于新的下载。', defaultTooltip: '对新的下载使用当前默认值。', + blankUsesDefault: '留空 · 使用默认值', + usingDefault: '使用默认值', + customPerDownload: '此下载的自定义值', identityReadOnly: '文件标识为只读。传输设置会保存以备重新下载使用。', transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。', download: '下载', diff --git a/src/i18n/resources.test.ts b/src/i18n/resources.test.ts index 51d921e..7c2929b 100644 --- a/src/i18n/resources.test.ts +++ b/src/i18n/resources.test.ts @@ -112,6 +112,7 @@ describe('translation catalogs', () => { 'settings.network.firefoxMacos', 'settings.network.safariMacos', 'settings.network.torrentExternalIpPlaceholder', + 'properties.inputFormatPiecePriority', ]); const unexpectedDuplicates = duplicates diff --git a/src/index.css b/src/index.css index 257efb3..153e347 100644 --- a/src/index.css +++ b/src/index.css @@ -572,8 +572,8 @@ html[data-list-density="relaxed"] { } .properties-window-shell { - --properties-header-surface: hsl(var(--surface-raised)); - --properties-card-surface: hsl(var(--bg-input) / 0.42); + --properties-header-surface: hsl(var(--main-bg)); + --properties-card-surface: hsl(var(--surface-raised) / 0.38); position: relative; min-height: 100%; overflow: hidden; @@ -612,8 +612,10 @@ html[data-list-density="relaxed"] { } .properties-window-header { - background: var(--properties-header-surface); - box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025); + background: + radial-gradient(circle at 100% 0%, hsl(var(--accent-color) / 0.055), transparent 38%), + var(--properties-header-surface); + box-shadow: inset 0 -1px 0 hsl(var(--text-primary) / 0.025); } .properties-window-hero-top { @@ -793,7 +795,8 @@ html[data-list-density="relaxed"] { padding: 8px 10px; border: 1px solid hsl(var(--border-modal) / 0.72); border-radius: 8px; - background: hsl(var(--bg-input) / 0.28); + background: var(--properties-card-surface); + box-shadow: inset 0 1px 0 hsl(var(--text-primary) / 0.025); } .properties-metric-card > svg { @@ -856,7 +859,7 @@ html[data-list-density="relaxed"] { gap: 10px; padding: 7px 16px; border-bottom: 1px solid hsl(var(--border-modal)); - background: hsl(var(--surface-raised)); + background: hsl(var(--surface-raised) / 0.42); } .properties-window-tab-navigation--overflow .properties-window-tabs { @@ -968,6 +971,209 @@ html[data-list-density="relaxed"] { border-radius: 8px; } + .properties-options { + max-width: 940px; + } + + .properties-options-intro, + .properties-option-group { + border: 1px solid hsl(var(--border-modal) / 0.82); + border-radius: 12px; + background: hsl(var(--surface-raised) / 0.22); + } + + .properties-options-intro { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 14px 16px; + box-shadow: inset 0 1px 0 hsl(var(--text-primary) / 0.025); + } + + .properties-section-eyebrow, + .properties-option-group-heading h2 { + color: hsl(var(--text-primary)); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; + } + + .properties-default-legend { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + color: hsl(var(--text-muted)); + font-size: 11px; + white-space: nowrap; + } + + .properties-default-legend-dot { + width: 7px; + height: 7px; + border: 1px solid hsl(var(--accent-color) / 0.72); + border-radius: 50%; + background: hsl(var(--accent-color) / 0.18); + } + + .properties-option-group { + padding: 16px; + } + + .properties-option-group-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 11px; + border-bottom: 1px solid hsl(var(--border-modal) / 0.64); + } + + .properties-option-group-heading p { + max-width: 680px; + margin-top: 4px; + color: hsl(var(--text-muted)); + font-size: 11px; + line-height: 1.45; + } + + .properties-field { + min-width: 0; + } + + .properties-field-label { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + color: hsl(var(--text-muted)); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + } + + .properties-field-meta { + overflow: hidden; + margin-inline-start: auto; + color: hsl(var(--accent-color) / 0.88); + font-size: 10px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .properties-help { + position: relative; + display: inline-flex; + flex: 0 0 auto; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 0; + padding: 0; + background: transparent; + color: hsl(var(--text-muted)); + cursor: help; + font: inherit; + outline: none; + } + + .properties-help:hover, + .properties-help:focus-visible { + color: hsl(var(--accent-color)); + background: hsl(var(--accent-color) / 0.1); + } + + .properties-help:focus-visible { + outline: 2px solid hsl(var(--accent-color) / 0.72); + outline-offset: 2px; + } + + .properties-help-tooltip { + position: absolute; + z-index: 30; + inset-inline-start: 0; + top: calc(100% + 7px); + width: min(260px, 56vw); + padding: 8px 10px; + border: 1px solid hsl(var(--border-modal)); + border-radius: 8px; + background: hsl(var(--surface-overlay) / 0.98); + box-shadow: 0 10px 26px hsl(var(--shadow-color)); + color: hsl(var(--text-primary)); + font-size: 11px; + font-weight: 500; + line-height: 1.45; + opacity: 0; + pointer-events: none; + transform: translateY(-2px); + transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease; + visibility: hidden; + } + + .properties-help:hover .properties-help-tooltip, + .properties-help:focus-visible .properties-help-tooltip { + opacity: 1; + transform: translateY(0); + visibility: visible; + } + + .properties-options .properties-option-group:last-child .properties-help-tooltip { + top: auto; + bottom: calc(100% + 7px); + transform: translateY(2px); + } + + .properties-options .properties-option-group:last-child .properties-help:hover .properties-help-tooltip, + .properties-options .properties-option-group:last-child .properties-help:focus-visible .properties-help-tooltip { + transform: translateY(0); + } + + .properties-option-toggle { + display: flex !important; + min-width: 0; + min-height: 56px; + align-items: flex-start !important; + flex-direction: row !important; + gap: 10px !important; + padding: 10px 11px; + border: 1px solid hsl(var(--border-modal) / 0.82); + border-radius: 9px; + background: hsl(var(--bg-input) / 0.2); + } + + .properties-option-toggle:has(input:not(:disabled)):hover { + border-color: hsl(var(--accent-color) / 0.42); + background: hsl(var(--item-hover)); + } + + .properties-option-toggle input { + margin-top: 2px !important; + } + + .properties-option-toggle-copy { + display: grid; + min-width: 0; + gap: 3px; + flex: 1 1 auto; + } + + .properties-option-toggle-label { + color: hsl(var(--text-primary)); + font-size: 12px; + line-height: 1.35; + } + + .properties-option-toggle-hint { + color: hsl(var(--text-muted)); + font-size: 10px; + line-height: 1.4; + } + .properties-window-panel textarea.app-control { min-height: 104px; resize: vertical; @@ -997,6 +1203,41 @@ html[data-list-density="relaxed"] { margin-top: 2px; } + .properties-window-panel .properties-field-label { + display: flex; + min-height: 20px; + align-items: center; + flex-direction: row; + gap: 6px; + line-height: 1.35; + } + + .properties-field-label-text { + display: inline-flex !important; + min-width: 0; + align-items: center; + color: inherit; + font: inherit; + line-height: inherit; + } + + .properties-field-format { + display: block; + margin-top: 5px; + color: hsl(var(--text-muted) / 0.88); + font-size: 10px; + line-height: 1.35; + } + + .properties-option-toggle-label { + display: block !important; + margin: 0; + color: hsl(var(--text-primary)); + font-size: 12px; + font-weight: 500; + line-height: 1.35; + } + .properties-window-panel > div > p, .properties-window-panel .text-text-muted { line-height: 1.45; diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index e09d446..b25aeb2 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -17,6 +17,8 @@ import { beginExclusivePropertiesAction, classifyPropertiesActionRequest, createFrameCoalescer, + decodePropertiesPatchValue, + encodePropertiesPatchValue, enqueuePropertiesAction, formatPropertiesQueuePlacement, getPropertiesLifecycleAction, @@ -33,6 +35,17 @@ import { } from './propertiesBridge'; describe('Properties window bridge', () => { + it('keeps optional override resets explicit across the JSON IPC boundary', () => { + const encoded = encodePropertiesPatchValue(undefined); + + expect(encoded).toBeNull(); + expect(JSON.parse(JSON.stringify({ torrentEncryptionPolicy: encoded }))).toEqual({ + torrentEncryptionPolicy: null, + }); + expect(decodePropertiesPatchValue(encoded)).toBeUndefined(); + expect(decodePropertiesPatchValue('prealloc')).toBe('prealloc'); + }); + it('uses a WebviewWindow target for directed child events', () => { expect(propertiesWindowEventTarget('properties-1')).toEqual({ kind: 'WebviewWindow', diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index ad8610b..8652a11 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -193,13 +193,48 @@ export type SecretPatch = | { kind: 'replace'; value: string } | { kind: 'clear' }; -export type PropertiesPatch = Partial> & { +export const PROPERTIES_PATCH_CLEARABLE_KEYS = [ + 'destination', + 'speedLimit', + 'torrentTrackers', + 'torrentExcludeTrackers', + 'torrentSeedTime', + 'torrentSeedRatio', + 'torrentUploadLimit', + 'torrentMaxPeers', + 'torrentPeerSpeedLimit', + 'torrentTrackerConnectTimeout', + 'torrentTrackerTimeout', + 'torrentTrackerInterval', + 'torrentStopTimeout', + 'torrentPrioritizePiece', + 'torrentEncryptionPolicy', + 'torrentFileAllocation', +] as const; + +type PropertiesPatchClearableKey = typeof PROPERTIES_PATCH_CLEARABLE_KEYS[number]; + +type PropertiesPatchClearableValues = { + [Key in PropertiesPatchClearableKey]?: DownloadItem[Key] | null; +}; + +export type PropertiesPatch = Partial> & PropertiesPatchClearableValues & { username?: SecretPatch; password?: SecretPatch; cookies?: SecretPatch; headers?: SecretPatch; }; +// Tauri command arguments cross a JSON boundary. `undefined` object members +// may be omitted before they reach Rust, so nullable fields are the explicit +// wire-level sentinel for clearing an optional per-download override. +export const encodePropertiesPatchValue = (value: T | undefined): T | null => value ?? null; + +export const decodePropertiesPatchValue = (value: T | null | undefined): T | undefined => + value === null ? undefined : value; + export type PropertiesAction = | 'apply-properties' | 'set-torrent-file-selection'