From 4a83ac97c788340e6d014d72e517dc25c566ebc9 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 5 Aug 2026 10:49:53 +0330 Subject: [PATCH] fix(properties): stabilize diagnostics and refresh download properties UI --- src/components/PropertiesWindowApp.tsx | 229 +++++++++--- src/components/PropertiesWindowBridgeHost.tsx | 7 +- src/i18n/catalogs/en.ts | 1 + src/i18n/catalogs/fa.ts | 1 + src/i18n/catalogs/he.ts | 1 + src/i18n/catalogs/ru.ts | 1 + src/i18n/catalogs/uk.ts | 1 + src/i18n/catalogs/zh-CN.ts | 1 + src/index.css | 346 ++++++++++++++++++ src/propertiesBridge.test.ts | 65 ++++ src/propertiesBridge.ts | 46 +++ 11 files changed, 643 insertions(+), 56 deletions(-) diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index aa80898..fc014ea 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -3,7 +3,7 @@ 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 { Copy, FileDown, FolderOpen, Pause, Play, RefreshCw, Save, X } from 'lucide-react'; +import { Activity, Copy, Download, FileDown, FolderOpen, Gauge, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, Timer, Upload, Users, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot'; import type { TorrentDetails } from '../bindings/TorrentDetails'; @@ -15,16 +15,20 @@ import { PROPERTIES_WINDOW_REMOVED, PROPERTIES_WINDOW_SNAPSHOT, attachAsyncPropertiesListener, + formatPropertiesQueuePlacement, getPropertiesLifecycleAction, propertiesLifecycleReachedPostcondition, propertiesTorrentPeerLimit, + propertiesDiagnosticRequestState, sendPropertiesActionRequest, sendPropertiesReady, isExpectedPropertiesDiagnosticUnavailable, + propertiesDiagnosticPhase, type PropertiesAction, type PropertiesActionRequest, type PropertiesActionResult, type PropertiesPatch, + type PropertiesDiagnosticPhase, type PropertiesSnapshot, type PropertiesSnapshotEvent, } from '../propertiesBridge'; @@ -64,6 +68,31 @@ const safeTitle = (name: string) => { const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); +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 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 currentWindow = useMemo(() => getCurrentWindow(), []); @@ -84,6 +113,8 @@ export const PropertiesWindowApp = () => { const [details, setDetails] = useState(null); const [diagnosticError, setDiagnosticError] = useState(''); const [diagnosticsLoading, setDiagnosticsLoading] = useState(false); + const [diagnosticsRefreshing, setDiagnosticsRefreshing] = useState(false); + const [diagnosticPhase, setDiagnosticPhase] = 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); @@ -129,9 +160,20 @@ export const PropertiesWindowApp = () => { 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); 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(() => isTorrent @@ -173,25 +215,50 @@ export const PropertiesWindowApp = () => { }); }, []); - const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string) => { + const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string, manual = false) => { if (!isTorrentDiagnosticsStatus(snapshotRef.current?.status ?? '')) return; - const requestKey = `${id}:${tab}`; + 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 ?? ''); + && 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()) { - setDiagnosticError(''); - setDiagnosticsLoading(true); + const cached = hasCachedResult(); + const requestState = propertiesDiagnosticRequestState(cached, hasPreviousAttempt, manual); + setDiagnosticsLoading(requestState.loading); + setDiagnosticsRefreshing(requestState.refreshing); + // 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); + 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); + if (isCurrent()) { + setFileProgress(nextProgress); + setDiagnosticPhase(propertiesDiagnosticPhase(false, 'success')); + } } else if (tab === 'peers') { const [peerResult, availabilityResult] = await Promise.allSettled([ invoke('get_torrent_peers', { id }), @@ -199,14 +266,22 @@ export const PropertiesWindowApp = () => { ]); if (isCurrent()) { if (peerResult.status === 'fulfilled') setPeers(peerResult.value); - else setPeers(null); if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value); - else setAvailability(null); - const unexpectedErrors = [peerResult, availabilityResult] + const rejectedResults = [peerResult, availabilityResult] .filter((result): result is PromiseRejectedResult => result.status === 'rejected') - .map(result => result.reason) + .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) { @@ -217,19 +292,19 @@ export const PropertiesWindowApp = () => { // not a diagnostic failure, and must not flash a raw backend error. if (isExpectedPropertiesDiagnosticUnavailable(error)) { setDiagnosticError(''); - if (tab === 'files') setFileProgress(null); - if (tab === 'peers') { - setPeers(null); - setAvailability(null); - } + setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'expected-unavailable')); } else { setDiagnosticError(message); + setDiagnosticPhase(propertiesDiagnosticPhase(hasCachedResult(), 'unexpected-error')); } } } finally { diagnosticsInFlightRef.current.delete(requestKey); - if (downloadIdRef.current === id && activeTabRef.current === tab) { + if (downloadIdRef.current === id + && activeTabRef.current === tab + && diagnosticLifecycleEpochRef.current === requestLifecycleEpoch) { setDiagnosticsLoading(false); + setDiagnosticsRefreshing(false); } } }, []); @@ -255,6 +330,9 @@ export const PropertiesWindowApp = () => { 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 lostApply = lostAction === 'apply-properties'; // A main-webview restart can lose both the action-result event and @@ -283,6 +361,12 @@ export const PropertiesWindowApp = () => { } 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(); + } await changeAppLocale(event.payload.snapshot.appearance.locale); if (event.payload.revision !== latestSnapshotRevisionRef.current) return; appearanceCleanupRef.current?.(); @@ -366,6 +450,9 @@ export const PropertiesWindowApp = () => { window.clearInterval(readyHeartbeatTimer); readyHeartbeatTimer = undefined; } + diagnosticLifecycleEpochRef.current += 1; + diagnosticLifecycleKeyRef.current = ''; + diagnosticAttemptsRef.current.clear(); setSnapshot(null); setNotice(t($ => $.downloadTable.noDownloads)); } @@ -422,12 +509,19 @@ export const PropertiesWindowApp = () => { setAvailability(null); setDiagnosticError(''); setDiagnosticsLoading(false); + setDiagnosticsRefreshing(false); + diagnosticLifecycleEpochRef.current += 1; + diagnosticLifecycleKeyRef.current = ''; + diagnosticAttemptsRef.current.clear(); + setDiagnosticPhase('idle'); return; } if (!isTorrentPollingStatus(snapshot.status)) { setFileProgress(null); setPeers(null); setAvailability(null); + diagnosticAttemptsRef.current.clear(); + setDiagnosticPhase('idle'); } void refreshDiagnostics(activeTab, downloadId); if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return; @@ -664,16 +758,25 @@ export const PropertiesWindowApp = () => { ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); const statusLabel = t($ => $.downloads.status[snapshot.status]); - const torrentMaxPeersLabel = snapshot.torrentMaxPeers === undefined - ? `${propertiesTorrentPeerLimit(snapshot.torrentMaxPeers)}${t($ => $.properties.defaultValue)}` - : snapshot.torrentMaxPeers === 0 - ? t($ => $.speedLimiter.unlimited) - : String(snapshot.torrentMaxPeers); const connectionMetric = isTorrent - ? `${snapshot.connectedPeers ?? '—'} ${t($ => $.properties.torrentConnectedPeers)} · ${torrentMaxPeersLabel} ${t($ => $.properties.torrentMaxPeers)}` + ? 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.details); @@ -688,17 +791,22 @@ export const PropertiesWindowApp = () => { return (
-
-
-
-

{snapshot.fileName}

-

{statusLabel} · {Math.round(progress * 100)}% · {total}

+
+
+
+
+

{snapshot.fileName}

+ {statusLabel} +
+

{queuePlacement}

-
$.actions.continue)}> +
$.actions.continue)}> {lifecycleAction && } {isTorrent && <> - - - +
+ + + +
+
+ $.downloads.actions.options)} aria-label={t($ => $.downloads.actions.options)}> +
+ + + +
+
}
-
$.properties.progress)}> -
+
+
$.properties.progress)} role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}> +
+
+ {progressPercent}
-
- {formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total} - {snapshot.speed || '—'} - {snapshot.eta || '—'} - {connectionMetric} - {isTorrent && {formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}} +
+
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
+
{t($ => $.properties.speed)}{snapshot.speed || '—'}
+
{t($ => $.properties.eta)}{snapshot.eta || '—'}
+
{isTorrent ? t($ => $.properties.torrentConnectedPeers) : t($ => $.properties.connections)}{connectionMetric}
+ {isTorrent && <> +
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
+
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
+ }
+
{snapshot.destination || '—'}
-
+
{activeTab === 'overview' &&
@@ -775,7 +894,7 @@ export const PropertiesWindowApp = () => {
{t($ => $.properties.dateAdded)}{snapshot.dateAdded || '—'} {t($ => $.properties.lastTry)}{snapshot.lastTry || '—'} - {t($ => $.properties.queueId)}{snapshot.queueId || '—'}{snapshot.queuePosition === undefined ? '' : ` · ${snapshot.queuePosition + 1}`} + {t($ => $.properties.queueId)}{queuePlacement} {t($ => $.properties.resumable)}{snapshot.resumable === false ? '—' : '✓'} {snapshot.lastError && <>{t($ => $.properties.lastError)}{snapshot.lastError}}
@@ -799,10 +918,10 @@ export const PropertiesWindowApp = () => {
} {activeTab === 'files' && isTorrent &&
-
+
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index + 1} ${file.relativePath}`} />{file.index + 1}{file.relativePath}{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
- {diagnosticsLoading &&

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

} - {!diagnosticsLoading && !fileProgress && !diagnosticError &&

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

} + {diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress &&

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

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

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

} {diagnosticError &&

{diagnosticError}

}
} @@ -819,7 +938,7 @@ export const PropertiesWindowApp = () => {
} {activeTab === 'peers' && isTorrent &&
-

{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : diagnosticsLoading ? t($ => $.properties.torrentPeerDiagnosticsLoading) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}

+

{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : diagnosticsLoading ? t($ => $.properties.torrentPeerDiagnosticsLoading) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}

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

{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}

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

{peers?.truncated ? t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers }) : peers?.peers.length ?? 0}

{peers?.peers.map((peer, index) => )}
{t($ => $.properties.torrentPeerAddress)}{t($ => $.properties.torrentPeerDownload)}{t($ => $.properties.torrentPeerUpload)}{t($ => $.properties.torrentPeerSeeder)}{t($ => $.properties.torrentPeerChoking)}
{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}

} diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 2dffd1a..8592cfb 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -193,8 +193,11 @@ export const PropertiesWindowBridgeHost = () => { const sendFor = async (windowLabel: string, downloadId: string) => { const registration = windows.get(windowLabel); if (!registration || registration.downloadId !== downloadId || disposed) return false; - const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId); + const store = useDownloadStore.getState(); + const item = store.downloads.find(download => download.id === downloadId); if (!item) return false; + const queue = store.queues.find(candidate => candidate.id === item.queueId) + ?? store.queues.find(candidate => candidate.isMain); const settings = useSettingsStore.getState(); const progress = useDownloadProgressStore.getState(); const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1; @@ -214,6 +217,8 @@ export const PropertiesWindowBridgeHost = () => { }, { progress: progress.progressMap[downloadId], moveProgress: progress.moveProgressMap[downloadId], + }, { + queueName: queue?.name, }), }); return true; diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 07cfe49..624f67f 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -225,6 +225,7 @@ const common = { connectedPeers: 'connected peers', details: 'Details', queueId: 'Queue', + queuePosition: 'Position {{position}}', resumable: 'Resumable', connectionCount: '{{active}}/{{total}}', connectionCountUnknown: '—/{{total}}', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 00daa49..d80a0e9 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -225,6 +225,7 @@ const fa = { connectedPeers: 'همتای متصل', details: 'جزئیات', queueId: 'صف', + queuePosition: 'موقعیت {{position}}', resumable: 'قابل ادامه', connectionCount: '{{active}}/{{total}} فعال', connectionCountUnknown: '—/{{total}} فعال', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 1a4c7cc..f9b5fa8 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -225,6 +225,7 @@ const he = { connectedPeers: 'עמיתים מחוברים', details: 'פרטים', queueId: 'תור', + queuePosition: 'מיקום {{position}}', resumable: 'ניתן להמשך', connectionCount: '{{active}}/{{total}} פעילות', connectionCountUnknown: '—/{{total}} פעילות', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 50df98e..73fa116 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -225,6 +225,7 @@ const ru = { connectedPeers: 'подключённых пиров', details: 'Подробности', queueId: 'Очередь', + queuePosition: 'Позиция {{position}}', resumable: 'Возобновляемая', connectionCount: '{{active}}/{{total}} активных', connectionCountUnknown: '—/{{total}} активных', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 2f4cf79..d778fe8 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -225,6 +225,7 @@ const uk = { connectedPeers: 'підключених пірів', details: 'Деталі', queueId: 'Черга', + queuePosition: 'Позиція {{position}}', resumable: 'Можна продовжити', connectionCount: '{{active}}/{{total}} активних', connectionCountUnknown: '—/{{total}} активних', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index ef26a64..fef3b26 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -225,6 +225,7 @@ const zhCN = { connectedPeers: '已连接对等端', details: '详细信息', queueId: '队列', + queuePosition: '位置 {{position}}', resumable: '可续传', connectionCount: '{{active}}/{{total}} 个连接', connectionCountUnknown: '—/{{total}} 个连接', diff --git a/src/index.css b/src/index.css index 656ad9d..6205a08 100644 --- a/src/index.css +++ b/src/index.css @@ -571,6 +571,352 @@ html[data-list-density="relaxed"] { background: hsl(var(--accent-color) / 0.9); } + .properties-window-shell { + --properties-header-surface: hsl(var(--surface-raised) / 0.88); + --properties-card-surface: hsl(var(--bg-input) / 0.42); + } + + .properties-window-header { + background: + radial-gradient(circle at 100% 0%, hsl(var(--accent-color) / 0.1), transparent 38%), + var(--properties-header-surface); + box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025); + } + + .properties-window-hero-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + } + + .properties-window-title-block { + min-width: 0; + flex: 1 1 auto; + } + + .properties-window-title-line { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; + } + + .properties-window-title-line h1 { + min-width: 0; + letter-spacing: -0.015em; + } + + .properties-window-queue { + display: block; + min-width: 0; + margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .properties-status-pill { + display: inline-flex; + flex: 0 0 auto; + min-height: 20px; + align-items: center; + padding: 2px 8px; + border: 1px solid hsl(var(--border-modal)); + border-radius: 999px; + font-size: 10px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + } + + .properties-status-downloading { color: hsl(var(--status-downloading)); background: hsl(var(--status-downloading) / 0.1); } + .properties-status-paused { color: hsl(var(--status-paused)); background: hsl(var(--status-paused) / 0.1); } + .properties-status-seeding { color: hsl(262 83% 62%); background: hsl(262 83% 58% / 0.12); } + .properties-status-failed { color: hsl(var(--status-failed)); background: hsl(var(--status-failed) / 0.1); } + .properties-status-completed { color: hsl(var(--status-completed)); background: hsl(var(--status-completed) / 0.1); } + .properties-status-processing { color: hsl(199 89% 58%); background: hsl(199 89% 48% / 0.1); } + .properties-status-queued { color: hsl(var(--status-queued)); background: hsl(var(--status-queued) / 0.1); } + .properties-status-retrying { color: hsl(var(--status-retrying)); background: hsl(var(--status-retrying) / 0.1); } + + .properties-window-command-bar, + .properties-secondary-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + } + + .properties-primary-action { + min-height: 32px; + border-radius: 8px; + box-shadow: 0 3px 10px hsl(var(--accent-color) / 0.2); + } + + .properties-command-button { + min-height: 32px; + border-radius: 8px; + } + + .properties-command-overflow { + position: relative; + display: none; + } + + .properties-command-overflow > summary { + list-style: none; + cursor: default; + } + + .properties-command-overflow > summary::-webkit-details-marker { + display: none; + } + + .properties-command-menu { + position: absolute; + z-index: 20; + inset-inline-end: 0; + top: calc(100% + 7px); + display: grid; + min-width: 190px; + gap: 2px; + padding: 5px; + border: 1px solid hsl(var(--border-modal)); + border-radius: 9px; + background: hsl(var(--surface-overlay) / 0.98); + box-shadow: 0 12px 28px hsl(var(--shadow-color)); + } + + .properties-command-menu button { + display: flex; + min-height: 30px; + align-items: center; + gap: 8px; + padding: 5px 8px; + border-radius: 6px; + color: hsl(var(--text-primary)); + font-size: 12px; + text-align: start; + } + + .properties-command-menu button:hover:not(:disabled), + .properties-command-menu button:focus-visible { + background: hsl(var(--item-hover)); + outline: none; + } + + .properties-window-progress-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 16px; + } + + .properties-window-progress-track { + min-width: 0; + flex: 1 1 auto; + height: 10px; + overflow: hidden; + border-radius: 999px; + background: hsl(var(--border-color)); + box-shadow: inset 0 1px 2px hsl(0 0% 0% / 0.25); + } + + .properties-window-progress-fill { + height: 100%; + border-radius: inherit; + transition: width 180ms ease; + } + + .properties-progress-downloading { background: hsl(var(--status-downloading)); } + .properties-progress-paused { background: hsl(var(--status-paused)); } + .properties-progress-seeding { background: hsl(262 83% 58%); } + .properties-progress-failed { background: hsl(var(--status-failed)); } + .properties-progress-completed { background: hsl(var(--status-completed)); } + .properties-progress-processing { background: hsl(199 89% 48%); } + .properties-progress-queued { background: hsl(var(--status-queued)); } + .properties-progress-retrying { background: hsl(var(--status-retrying)); } + + .properties-window-progress-percent { + min-width: 42px; + color: hsl(var(--text-secondary)); + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: end; + } + + .properties-window-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); + gap: 7px; + margin-top: 11px; + } + + .properties-metric-card { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border: 1px solid hsl(var(--border-modal) / 0.72); + border-radius: 8px; + background: hsl(var(--bg-input) / 0.28); + } + + .properties-metric-card > svg { + flex: 0 0 auto; + margin-top: 2px; + color: hsl(var(--accent-color)); + } + + .properties-metric-card > div { + display: grid; + min-width: 0; + gap: 2px; + } + + .properties-metric-card span { + overflow: hidden; + color: hsl(var(--text-muted)); + font-size: 10px; + font-weight: 650; + letter-spacing: 0.025em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + + .properties-metric-card strong { + overflow: hidden; + color: hsl(var(--text-primary)); + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; + } + + .properties-window-destination { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + margin-top: 10px; + color: hsl(var(--text-muted)); + font-size: 11px; + } + + .properties-window-destination span { + min-width: 0; + overflow: hidden; + direction: ltr; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; + } + + .properties-window-panel { + scrollbar-gutter: stable; + } + + .properties-window-panel .app-control { + min-height: 34px; + padding: 7px 11px; + border-radius: 8px; + } + + .properties-window-panel textarea.app-control { + min-height: 104px; + resize: vertical; + line-height: 1.45; + } + + .properties-window-panel label:not(:has(> input[type="checkbox"])) { + display: flex; + min-width: 0; + align-items: stretch; + flex-direction: column; + gap: 6px; + line-height: 1.35; + } + + .properties-window-panel label:not(:has(> input[type="checkbox"])) > .app-control { + margin-top: 0 !important; + } + + .properties-window-panel label:has(> input[type="checkbox"]) { + min-width: 0; + line-height: 1.4; + } + + .properties-window-panel label:has(> input[type="checkbox"]) input { + flex: 0 0 auto; + margin-top: 2px; + } + + .properties-window-panel > div > p, + .properties-window-panel .text-text-muted { + line-height: 1.45; + } + + .properties-window-panel table th, + .properties-window-panel table td { + padding-inline: 10px; + padding-block: 8px; + vertical-align: middle; + } + + @media (max-width: 720px) { + .properties-window-hero-top { + align-items: stretch; + flex-direction: column; + gap: 11px; + } + + .properties-window-command-bar { + justify-content: flex-end; + } + + .properties-secondary-actions { + display: none; + } + + .properties-command-overflow { + display: inline-block; + } + + .properties-window-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 440px) { + .properties-window-header { + padding-inline: 14px; + } + + .properties-window-panel { + padding: 14px; + } + + .properties-window-metrics { + grid-template-columns: minmax(0, 1fr); + } + + .properties-command-label { + max-width: 26vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + @media (prefers-reduced-motion: reduce) { + .properties-window-progress-fill { + transition: none; + } + } + .app-icon-button { display: inline-flex; width: 28px; diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index 81d9b1e..50cfd28 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -16,8 +16,11 @@ import { beginExclusivePropertiesAction, createFrameCoalescer, enqueuePropertiesAction, + formatPropertiesQueuePlacement, getPropertiesLifecycleAction, isExpectedPropertiesDiagnosticUnavailable, + propertiesDiagnosticPhase, + propertiesDiagnosticRequestState, propertiesLifecycleReachedPostcondition, propertiesTorrentPeerLimit, sanitizePropertiesSnapshot, @@ -156,6 +159,68 @@ describe('Properties window bridge', () => { expect(normalSnapshot).not.toHaveProperty('connectedPeers'); }); + it('adds a user-facing queue name to the sanitized snapshot', () => { + const snapshot = sanitizePropertiesSnapshot({ + id: 'queued-1', + fileName: 'example.bin', + url: 'https://example.test/file', + status: 'queued', + queueId: 'internal-queue-id', + queuePosition: 2, + } as DownloadItem, { + theme: 'dark', + fontFamily: 'system', + appFontSize: 'standard', + listRowDensity: 'standard', + locale: 'en', + }, undefined, { queueName: 'Main Queue' }); + + expect(snapshot.queueName).toBe('Main Queue'); + expect(snapshot.queueId).toBe('internal-queue-id'); + }); + + it('keeps diagnostic refreshes quiet when cached data exists', () => { + expect(propertiesDiagnosticPhase(false, 'request-start')).toBe('initial'); + expect(propertiesDiagnosticPhase(false, 'request-start', true)).toBe('refreshing'); + expect(propertiesDiagnosticPhase(true, 'request-start')).toBe('refreshing'); + expect(propertiesDiagnosticPhase(true, 'success')).toBe('idle'); + expect(propertiesDiagnosticPhase(true, 'expected-unavailable')).toBe('stale'); + expect(propertiesDiagnosticPhase(false, 'expected-unavailable')).toBe('unavailable'); + expect(propertiesDiagnosticPhase(true, 'unexpected-error')).toBe('error'); + expect(propertiesDiagnosticRequestState(false, false, false)).toMatchObject({ + loading: true, + refreshing: false, + resetMessage: true, + phase: 'initial', + }); + expect(propertiesDiagnosticRequestState(false, true, false)).toMatchObject({ + loading: false, + refreshing: false, + resetMessage: false, + phase: 'refreshing', + }); + expect(propertiesDiagnosticRequestState(true, true, true)).toMatchObject({ + loading: false, + refreshing: true, + resetMessage: true, + phase: 'refreshing', + }); + }); + + it('formats queue placement without ever using a raw queue id', () => { + const formatPosition = (position: number) => `Position ${position}`; + expect(formatPropertiesQueuePlacement('Main Queue', 2, formatPosition)) + .toBe('Main Queue · Position 3'); + expect(formatPropertiesQueuePlacement(undefined, 2, formatPosition)) + .toBe('Position 3'); + expect(formatPropertiesQueuePlacement('Main Queue', undefined, formatPosition)) + .toBe('Main Queue'); + expect(formatPropertiesQueuePlacement(' Main Queue ', 1.5, formatPosition)) + .toBe('Main Queue'); + expect(formatPropertiesQueuePlacement(undefined, Number.NaN, formatPosition)) + .toBe('—'); + }); + it('applies explicit secret changes without conflating unchanged fields', () => { expect(applySecretPatch(undefined, 'existing')).toBe('existing'); expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing'); diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index a90f7da..0056ec8 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -97,10 +97,54 @@ export const isExpectedPropertiesDiagnosticUnavailable = (error: unknown): boole ].includes(message); }; +export type PropertiesDiagnosticPhase = 'idle' | 'initial' | 'refreshing' | 'stale' | 'unavailable' | 'error'; +export type PropertiesDiagnosticOutcome = 'request-start' | 'success' | 'expected-unavailable' | 'unexpected-error'; + +export const propertiesDiagnosticPhase = ( + hasCachedResult: boolean, + outcome: PropertiesDiagnosticOutcome, + hasPreviousAttempt = false, +): PropertiesDiagnosticPhase => { + if (outcome === 'request-start') return hasCachedResult || hasPreviousAttempt ? 'refreshing' : 'initial'; + if (outcome === 'success') return 'idle'; + if (outcome === 'expected-unavailable') return hasCachedResult ? 'stale' : 'unavailable'; + return 'error'; +}; + +export const propertiesDiagnosticRequestState = ( + hasCachedResult: boolean, + hasPreviousAttempt: boolean, + manual: boolean, +) => ({ + loading: !hasPreviousAttempt && !hasCachedResult, + refreshing: manual && (hasPreviousAttempt || hasCachedResult), + resetMessage: !hasPreviousAttempt || hasCachedResult, + phase: propertiesDiagnosticPhase(hasCachedResult, 'request-start', hasPreviousAttempt), +}); + +export const formatPropertiesQueuePlacement = ( + queueName: unknown, + queuePosition: unknown, + formatPosition: (position: number) => string, +): string => { + const name = typeof queueName === 'string' ? queueName.trim() : ''; + const hasPosition = typeof queuePosition === 'number' + && Number.isInteger(queuePosition) + && queuePosition >= 0; + const position = hasPosition ? formatPosition(queuePosition + 1) : ''; + if (name && position) return `${name} · ${position}`; + return name || position || '—'; +}; + type SafePropertiesFields = Pick; +export type PropertiesSnapshotContext = { + queueName?: string; +}; + export type PropertiesSnapshot = SafePropertiesFields & { appearance: DocumentAppearance; + queueName?: string; activeConnections?: number; requestedConnections?: number; connectedPeers?: number; @@ -246,6 +290,7 @@ const copyWithoutSecrets = ( progress?: DownloadProgressEvent; moveProgress?: number; }, + context?: PropertiesSnapshotContext, ): PropertiesSnapshot => { const safeItem = Object.fromEntries( PROPERTIES_SNAPSHOT_KEYS.flatMap(key => ( @@ -256,6 +301,7 @@ const copyWithoutSecrets = ( return { ...safeItem, appearance, + ...(context?.queueName ? { queueName: context.queueName } : {}), ...(live?.progress ? { fraction: live.progress.fraction, speed: item.status === 'seeding'