From e402603edb4bf23f4ada316936e1ec546158387e Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 7 Aug 2026 12:24:05 +0330 Subject: [PATCH] fix(properties): harden diagnostic visual states --- src/components/PropertiesWindowApp.tsx | 88 +++++++++++++++++- 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 | 114 ++++++++++++++++++++---- src/utils/propertiesDiagnostics.test.ts | 36 ++++++++ src/utils/propertiesDiagnostics.ts | 41 +++++++++ 10 files changed, 266 insertions(+), 19 deletions(-) create mode 100644 src/utils/propertiesDiagnostics.test.ts create mode 100644 src/utils/propertiesDiagnostics.ts diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index b8c95d7..1727dc2 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -41,6 +41,10 @@ import { changeAppLocale } from '../i18n'; import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; import { getWindowControlRailWidth } from '../utils/windowControlStyle'; import { getPropertiesFooterActions } from '../utils/propertiesFooter'; +import { + 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'; @@ -200,6 +204,8 @@ export const PropertiesWindowApp = () => { 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); @@ -271,6 +277,8 @@ export const PropertiesWindowApp = () => { 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; @@ -433,6 +441,18 @@ export const PropertiesWindowApp = () => { 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. @@ -455,6 +475,8 @@ export const PropertiesWindowApp = () => { 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 }), @@ -462,6 +484,18 @@ export const PropertiesWindowApp = () => { 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); @@ -492,6 +526,15 @@ export const PropertiesWindowApp = () => { 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); @@ -563,6 +606,19 @@ export const PropertiesWindowApp = () => { 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 @@ -724,6 +780,8 @@ export const PropertiesWindowApp = () => { diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); + setPeerDiagnosticPhase('idle'); + setAvailabilityDiagnosticPhase('idle'); return; } if (!isTorrentPollingStatus(snapshot.status)) { @@ -732,6 +790,8 @@ export const PropertiesWindowApp = () => { setAvailability(null); diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); + setPeerDiagnosticPhase('idle'); + setAvailabilityDiagnosticPhase('idle'); } void refreshDiagnostics(activeTab, downloadId); if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return; @@ -1241,7 +1301,7 @@ 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}%)
+
{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return ; })}
{t($ => $.properties.torrentFileProgressSelected)}#{t($ => $.properties.torrentFileProgressPath)}{t($ => $.properties.size)}{t($ => $.properties.torrentFileProgressCompleted)}
{ const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index + 1} ${file.relativePath}`} />{file.index + 1}{file.relativePath}{formatDownloadBytes(file.length)}{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress &&

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

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

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

} {diagnosticError &&

{diagnosticError}

} @@ -1260,9 +1320,29 @@ export const PropertiesWindowApp = () => {
} {activeTab === 'peers' && isTorrent &&
-

{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 ? '✓' : '—'}
+
+
+
+ {t($ => $.properties.torrentPeerDiagnostics)} +

+ {peers + ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) + : 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 ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}

{availabilityDiagnosticPhase === 'stale' &&

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

}
+
{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/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 6612da5..2581de0 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -300,6 +300,7 @@ const common = { torrentPeerDiagnostics: 'Torrent peer diagnostics', torrentPeerDiagnosticsRefresh: 'Refresh', torrentPeerDiagnosticsLoading: 'Loading peer diagnostics…', + torrentPeerDiagnosticsStale: 'Showing the last validated result; refresh to check again.', torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active or paused.', torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.', torrentPeerDiagnosticsHint: 'Validated peer addresses and ports are shown ephemerally; peer IDs and raw bitfields are never retained.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 52a806a..c836ab6 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -300,6 +300,7 @@ const fa = { torrentPeerDiagnostics: 'اطلاعات همتاهای تورنت', torrentPeerDiagnosticsRefresh: 'تازه‌سازی', torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…', + torrentPeerDiagnosticsStale: 'آخرین نتیجهٔ معتبر نمایش داده می‌شود؛ برای بررسی دوباره تازه‌سازی کنید.', torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال یا متوقف بودن تورنت در دسترس است.', torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.', torrentPeerDiagnosticsHint: 'آدرس و پورت معتبر همتاها فقط به‌صورت موقت نمایش داده می‌شوند؛ شناسه همتا و بیت‌فیلد خام هرگز نگه‌داری نمی‌شود.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 306c4ec..bc82853 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -300,6 +300,7 @@ const he = { torrentPeerDiagnostics: 'אבחון עמיתי טורנט', torrentPeerDiagnosticsRefresh: 'רענון', torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…', + torrentPeerDiagnosticsStale: 'מוצגת התוצאה המאומתת האחרונה; רענן כדי לבדוק שוב.', torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל או מושהה.', torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.', torrentPeerDiagnosticsHint: 'כתובות ויציאות מאומתות של עמיתים מוצגות באופן זמני בלבד; מזהי עמיתים ושדות סיביות גולמיים לעולם אינם נשמרים.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index eef79cb..9d0c26c 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -300,6 +300,7 @@ const ru = { torrentPeerDiagnostics: 'Диагностика пиров торрента', torrentPeerDiagnosticsRefresh: 'Обновить', torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…', + torrentPeerDiagnosticsStale: 'Показан последний проверенный результат; обновите, чтобы проверить снова.', torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен или приостановлен.', torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.', torrentPeerDiagnosticsHint: 'Проверенные адреса и порты пиров показываются только временно; идентификаторы пиров и исходные битовые поля никогда не сохраняются.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index ac6308f..6ee7fb7 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -300,6 +300,7 @@ const uk = { torrentPeerDiagnostics: 'Діагностика пірів торрента', torrentPeerDiagnosticsRefresh: 'Оновити', torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…', + torrentPeerDiagnosticsStale: 'Показано останній перевірений результат; оновіть, щоб перевірити ще раз.', torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний або призупинений.', torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.', torrentPeerDiagnosticsHint: 'Перевірені адреси й порти пірів показуються лише тимчасово; ідентифікатори пірів і сирі бітові поля ніколи не зберігаються.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 25c145a..6cb9a7d 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -300,6 +300,7 @@ const zhCN = { torrentPeerDiagnostics: 'Torrent 对等节点诊断', torrentPeerDiagnosticsRefresh: '刷新', torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…', + torrentPeerDiagnosticsStale: '当前显示最近一次验证的结果;刷新以再次检查。', torrentPeerDiagnosticsUnavailable: 'Torrent 活跃或暂停时可查看对等节点诊断。', torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。', torrentPeerDiagnosticsHint: '仅临时显示经过验证的对等端地址和端口;永不保留对等端 ID 或原始位域。', diff --git a/src/index.css b/src/index.css index 153e347..c091a6d 100644 --- a/src/index.css +++ b/src/index.css @@ -11,6 +11,7 @@ --item-hover: 0 0% 0% / 0.05; --item-selected: 211 100% 50%; --accent-color: 211 100% 50%; + --properties-live-value-color: 211 100% 37%; --accent-foreground: 220 20% 8%; --stripe-bg: 0 0% 0% / 0.035; @@ -57,6 +58,7 @@ --item-hover: 0 0% 0% / 0.05; --item-selected: 211 100% 50%; --accent-color: 211 100% 50%; + --properties-live-value-color: 211 100% 37%; --accent-foreground: 220 20% 8%; --stripe-bg: 0 0% 0% / 0.035; --text-primary: 0 0% 10%; @@ -89,6 +91,7 @@ --item-hover: 0 0% 100% / 0.07; --item-selected: 211 100% 56%; --accent-color: 211 100% 56%; + --properties-live-value-color: 211 100% 66%; --accent-foreground: 220 20% 12%; --stripe-bg: 0 0% 100% / 0.025; --text-primary: 0 0% 91%; @@ -134,6 +137,7 @@ --item-hover: 326 100% 74% / 0.10; --item-selected: 326 100% 74%; --accent-color: 326 100% 74%; + --properties-live-value-color: 326 100% 74%; --accent-foreground: 220 20% 12%; --stripe-bg: 0 0% 100% / 0.025; --text-primary: 60 30% 96%; @@ -179,6 +183,7 @@ --item-hover: 193 43% 67% / 0.12; --item-selected: 193 43% 67%; --accent-color: 193 43% 67%; + --properties-live-value-color: 193 43% 67%; --accent-foreground: 220 20% 12%; --stripe-bg: 0 0% 100% / 0.025; --text-primary: 218 27% 92%; @@ -572,14 +577,17 @@ html[data-list-density="relaxed"] { } .properties-window-shell { - --properties-header-surface: hsl(var(--main-bg)); - --properties-card-surface: hsl(var(--surface-raised) / 0.38); + --properties-body-surface: hsl(var(--main-bg)); + --properties-header-surface: hsl(var(--bg-modal)); + --properties-card-surface: hsl(var(--bg-input)); + --properties-card-border: hsl(var(--border-modal)); + --properties-live-value: hsl(var(--properties-live-value-color)); position: relative; min-height: 100%; overflow: hidden; border: 1px solid hsl(var(--border-color)); border-radius: 18px; - background: hsl(var(--main-bg)); + background: var(--properties-body-surface); } .properties-window-titlebar { @@ -612,10 +620,10 @@ html[data-list-density="relaxed"] { } .properties-window-header { - 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); + background: var(--properties-header-surface); + box-shadow: + inset 0 -1px 0 var(--properties-card-border), + 0 8px 24px hsl(var(--shadow-color)); } .properties-window-hero-top { @@ -793,10 +801,12 @@ html[data-list-density="relaxed"] { align-items: flex-start; gap: 8px; padding: 8px 10px; - border: 1px solid hsl(var(--border-modal) / 0.72); + border: 1px solid var(--properties-card-border); border-radius: 8px; background: var(--properties-card-surface); - box-shadow: inset 0 1px 0 hsl(var(--text-primary) / 0.025); + box-shadow: + inset 0 1px 0 hsl(var(--text-primary) / 0.05), + 0 1px 2px hsl(var(--shadow-color)); } .properties-metric-card > svg { @@ -825,7 +835,7 @@ html[data-list-density="relaxed"] { .properties-metric-card strong { overflow: hidden; color: hsl(var(--text-primary)); - font-size: 12px; + font-size: 13px; font-variant-numeric: tabular-nums; font-weight: 650; text-overflow: ellipsis; @@ -858,8 +868,8 @@ html[data-list-density="relaxed"] { align-items: center; gap: 10px; padding: 7px 16px; - border-bottom: 1px solid hsl(var(--border-modal)); - background: hsl(var(--surface-raised) / 0.42); + border-bottom: 1px solid var(--properties-card-border); + background: hsl(var(--surface-raised)); } .properties-window-tab-navigation--overflow .properties-window-tabs { @@ -965,6 +975,80 @@ html[data-list-density="relaxed"] { scrollbar-gutter: stable; } + .properties-data-value { + color: var(--properties-live-value); + font-variant-numeric: tabular-nums; + font-weight: 700; + } + + .properties-diagnostic-card { + min-width: 0; + padding: 12px; + border: 1px solid var(--properties-card-border); + border-radius: 10px; + background: var(--properties-card-surface); + box-shadow: + inset 0 1px 0 hsl(var(--text-primary) / 0.04), + 0 1px 2px hsl(var(--shadow-color)); + } + + .properties-diagnostic-card[data-diagnostic-phase="stale"] { + border-style: dashed; + } + + .properties-diagnostic-card[data-diagnostic-phase="error"] { + border-color: hsl(var(--status-failed)); + } + + .properties-diagnostic-card[data-diagnostic-phase="unavailable"] .properties-diagnostic-value, + .properties-diagnostic-card[data-diagnostic-phase="initial"] .properties-diagnostic-value, + .properties-diagnostic-value[data-value-state="loading"], + .properties-diagnostic-value[data-value-state="unavailable"], + .properties-diagnostic-value[data-value-state="stale"], + .properties-diagnostic-value[data-value-state="error"] { + color: hsl(var(--text-secondary)); + } + + .properties-diagnostic-heading { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .properties-diagnostic-label { + color: hsl(var(--text-muted)); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.025em; + text-transform: uppercase; + } + + .properties-diagnostic-value { + margin-top: 6px; + color: var(--properties-live-value); + font-size: 15px; + font-variant-numeric: tabular-nums; + font-weight: 750; + line-height: 1.25; + } + + .properties-diagnostic-hint { + max-width: 60ch; + margin-top: 8px; + color: hsl(var(--text-secondary)); + font-size: 11px; + line-height: 1.45; + } + + .properties-diagnostic-detail { + margin-top: 8px; + color: hsl(var(--text-muted)); + font-size: 11px; + font-variant-numeric: tabular-nums; + } + .properties-window-panel .app-control { min-height: 34px; padding: 7px 11px; @@ -977,7 +1061,7 @@ html[data-list-density="relaxed"] { .properties-options-intro, .properties-option-group { - border: 1px solid hsl(var(--border-modal) / 0.82); + border: 1px solid var(--properties-card-border); border-radius: 12px; background: hsl(var(--surface-raised) / 0.22); } @@ -1028,7 +1112,7 @@ html[data-list-density="relaxed"] { gap: 12px; margin-bottom: 14px; padding-bottom: 11px; - border-bottom: 1px solid hsl(var(--border-modal) / 0.64); + border-bottom: 1px solid var(--properties-card-border); } .properties-option-group-heading p { @@ -1141,7 +1225,7 @@ html[data-list-density="relaxed"] { flex-direction: row !important; gap: 10px !important; padding: 10px 11px; - border: 1px solid hsl(var(--border-modal) / 0.82); + border: 1px solid var(--properties-card-border); border-radius: 9px; background: hsl(var(--bg-input) / 0.2); } diff --git a/src/utils/propertiesDiagnostics.test.ts b/src/utils/propertiesDiagnostics.test.ts new file mode 100644 index 0000000..295e267 --- /dev/null +++ b/src/utils/propertiesDiagnostics.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + getPropertiesAvailabilityDiagnosticState, + getPropertiesPeerDiagnosticState, +} from './propertiesDiagnostics'; + +const emptyPeerDiagnostics = { + totalPeers: 0, + totalSeeders: 0, + peers: [], + truncated: false, +}; + +describe('Properties peer diagnostics presentation state', () => { + it('keeps a genuine empty response live instead of treating it as unavailable', () => { + expect(getPropertiesPeerDiagnosticState(emptyPeerDiagnostics, false, 'idle')).toBe('live'); + }); + + it('distinguishes loading and unavailable before a response exists', () => { + expect(getPropertiesPeerDiagnosticState(null, true, 'initial')).toBe('loading'); + expect(getPropertiesPeerDiagnosticState(null, false, 'unavailable')).toBe('unavailable'); + }); + + it('marks cached diagnostics as stale after an expected lifecycle miss', () => { + expect(getPropertiesPeerDiagnosticState(emptyPeerDiagnostics, false, 'stale')).toBe('stale'); + }); + + it('marks cached peer diagnostics as errored after an unexpected refresh failure', () => { + expect(getPropertiesPeerDiagnosticState(emptyPeerDiagnostics, false, 'error')).toBe('error'); + }); + + it('does not reuse peer state for unavailable availability data', () => { + expect(getPropertiesAvailabilityDiagnosticState(null, false, 'idle')).toBe('unavailable'); + expect(getPropertiesAvailabilityDiagnosticState(null, false, 'error')).toBe('error'); + }); +}); diff --git a/src/utils/propertiesDiagnostics.ts b/src/utils/propertiesDiagnostics.ts new file mode 100644 index 0000000..ca71763 --- /dev/null +++ b/src/utils/propertiesDiagnostics.ts @@ -0,0 +1,41 @@ +import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics'; +import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot'; +import type { PropertiesDiagnosticPhase } from '../propertiesBridge'; + +export type PropertiesDiagnosticValueState = 'live' | 'loading' | 'stale' | 'error' | 'unavailable'; + +const getPropertiesDiagnosticValueState = ( + hasValue: boolean, + diagnosticsLoading: boolean, + diagnosticPhase: PropertiesDiagnosticPhase, +): PropertiesDiagnosticValueState => { + if (hasValue) { + if (diagnosticPhase === 'error') return 'error'; + if (diagnosticPhase === 'stale') return 'stale'; + return 'live'; + } + if (diagnosticsLoading) return 'loading'; + if (diagnosticPhase === 'error') return 'error'; + if (diagnosticPhase === 'stale') return 'stale'; + return 'unavailable'; +}; + +export type PropertiesPeerDiagnosticState = PropertiesDiagnosticValueState; + +export const getPropertiesPeerDiagnosticState = ( + peers: TorrentPeerDiagnostics | null, + diagnosticsLoading: boolean, + diagnosticPhase: PropertiesDiagnosticPhase, +): PropertiesPeerDiagnosticState => { + return getPropertiesDiagnosticValueState(peers !== null, diagnosticsLoading, diagnosticPhase); +}; + +export const getPropertiesAvailabilityDiagnosticState = ( + availability: TorrentAvailabilitySnapshot | null, + diagnosticsLoading: boolean, + diagnosticPhase: PropertiesDiagnosticPhase, +): PropertiesDiagnosticValueState => getPropertiesDiagnosticValueState( + availability !== null, + diagnosticsLoading, + diagnosticPhase, +);