fix(properties): harden diagnostic visual states

This commit is contained in:
NimBold
2026-08-07 12:24:05 +03:30
parent e2654510af
commit e402603edb
10 changed files with 266 additions and 19 deletions
+84 -4
View File
@@ -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<PropertiesDiagnosticPhase>('idle');
const [peerDiagnosticPhase, setPeerDiagnosticPhase] = useState<PropertiesDiagnosticPhase>('idle');
const [availabilityDiagnosticPhase, setAvailabilityDiagnosticPhase] = useState<PropertiesDiagnosticPhase>('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<number[] | null>(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 && <div className="space-y-3">
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { 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}`} /></td><td className="p-2">{file.index + 1}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { 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}`} /></td><td className="p-2">{file.index + 1}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
@@ -1260,9 +1320,29 @@ export const PropertiesWindowApp = () => {
</div>}
{activeTab === 'peers' && isTorrent && <div className="space-y-4">
<div className="flex items-center justify-between"><p className="text-sm">{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : diagnosticsLoading ? t($ => $.properties.torrentPeerDiagnosticsLoading) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}</p><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('peers', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentPeerDiagnosticsRefresh)}</button></div>
<div className="grid gap-3 sm:grid-cols-2"><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentAvailability)}</span><p className="mt-1">{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}</p></div><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentPeerDiagnosticsHint)}</span><p className="mt-1">{peers?.truncated ? t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers }) : peers?.peers.length ?? 0}</p></div></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentPeerAddress)}</th><th className="p-2">{t($ => $.properties.torrentPeerDownload)}</th><th className="p-2">{t($ => $.properties.torrentPeerUpload)}</th><th className="p-2">{t($ => $.properties.torrentPeerSeeder)}</th><th className="p-2">{t($ => $.properties.torrentPeerChoking)}</th></tr></thead><tbody>{peers?.peers.map((peer, index) => <tr key={`${peer.ip ?? 'peer'}-${peer.port ?? 'unknown'}-${index}`} className="border-t border-border-modal/60"><td className="p-2 font-mono">{peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'}</td><td className="p-2">{formatDownloadBytes(peer.downloadSpeed)}/s</td><td className="p-2">{formatDownloadBytes(peer.uploadSpeed)}/s</td><td className="p-2">{peer.seeder ? '✓' : '—'}</td><td className="p-2">{peer.peerChoking ? '✓' : '—'}</td></tr>)}</tbody></table></div>
<div
className="properties-diagnostic-card"
data-diagnostic-phase={peerDiagnosticPhase}
>
<div className="properties-diagnostic-heading">
<div className="min-w-0">
<span className="properties-diagnostic-label">{t($ => $.properties.torrentPeerDiagnostics)}</span>
<p className="properties-diagnostic-value" data-value-state={peerDiagnosticState} role="status">
{peers
? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders })
: diagnosticsLoading
? t($ => $.properties.torrentPeerDiagnosticsLoading)
: t($ => $.properties.torrentPeerDiagnosticsUnavailable)}
</p>
</div>
<button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('peers', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentPeerDiagnosticsRefresh)}</button>
</div>
<p className="properties-diagnostic-hint">{t($ => $.properties.torrentPeerDiagnosticsHint)}</p>
{peerDiagnosticPhase === 'stale' && <p className="properties-diagnostic-detail">{t($ => $.properties.torrentPeerDiagnosticsStale)}</p>}
{peers?.truncated && <p className="properties-diagnostic-detail">{t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers })}</p>}
</div>
<div className="properties-diagnostic-card" data-diagnostic-phase={availabilityDiagnosticPhase}><span className="properties-diagnostic-label">{t($ => $.properties.torrentAvailability)}</span><p className="properties-diagnostic-value" data-value-state={availabilityDiagnosticState}>{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}</p>{availabilityDiagnosticPhase === 'stale' && <p className="properties-diagnostic-detail">{t($ => $.properties.torrentPeerDiagnosticsStale)}</p>}</div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentPeerAddress)}</th><th className="p-2">{t($ => $.properties.torrentPeerDownload)}</th><th className="p-2">{t($ => $.properties.torrentPeerUpload)}</th><th className="p-2">{t($ => $.properties.torrentPeerSeeder)}</th><th className="p-2">{t($ => $.properties.torrentPeerChoking)}</th></tr></thead><tbody>{peers?.peers.map((peer, index) => <tr key={`${peer.ip ?? 'peer'}-${peer.port ?? 'unknown'}-${index}`} className="border-t border-border-modal/60"><td className="p-2 font-mono">{peer.ip ? `${peer.ip.includes(':') ? `[${peer.ip}]` : peer.ip}${peer.port == null ? '' : `:${peer.port}`}` : '—'}</td><td className="properties-data-value p-2">{formatDownloadBytes(peer.downloadSpeed)}/s</td><td className="properties-data-value p-2">{formatDownloadBytes(peer.uploadSpeed)}/s</td><td className="p-2">{peer.seeder ? '✓' : '—'}</td><td className="p-2">{peer.peerChoking ? '✓' : '—'}</td></tr>)}</tbody></table></div>
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
</div>}
+1
View File
@@ -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.',
+1
View File
@@ -300,6 +300,7 @@ const fa = {
torrentPeerDiagnostics: 'اطلاعات همتاهای تورنت',
torrentPeerDiagnosticsRefresh: 'تازه‌سازی',
torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…',
torrentPeerDiagnosticsStale: 'آخرین نتیجهٔ معتبر نمایش داده می‌شود؛ برای بررسی دوباره تازه‌سازی کنید.',
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال یا متوقف بودن تورنت در دسترس است.',
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
torrentPeerDiagnosticsHint: 'آدرس و پورت معتبر همتاها فقط به‌صورت موقت نمایش داده می‌شوند؛ شناسه همتا و بیت‌فیلد خام هرگز نگه‌داری نمی‌شود.',
+1
View File
@@ -300,6 +300,7 @@ const he = {
torrentPeerDiagnostics: 'אבחון עמיתי טורנט',
torrentPeerDiagnosticsRefresh: 'רענון',
torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…',
torrentPeerDiagnosticsStale: 'מוצגת התוצאה המאומתת האחרונה; רענן כדי לבדוק שוב.',
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל או מושהה.',
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
torrentPeerDiagnosticsHint: 'כתובות ויציאות מאומתות של עמיתים מוצגות באופן זמני בלבד; מזהי עמיתים ושדות סיביות גולמיים לעולם אינם נשמרים.',
+1
View File
@@ -300,6 +300,7 @@ const ru = {
torrentPeerDiagnostics: 'Диагностика пиров торрента',
torrentPeerDiagnosticsRefresh: 'Обновить',
torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…',
torrentPeerDiagnosticsStale: 'Показан последний проверенный результат; обновите, чтобы проверить снова.',
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен или приостановлен.',
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
torrentPeerDiagnosticsHint: 'Проверенные адреса и порты пиров показываются только временно; идентификаторы пиров и исходные битовые поля никогда не сохраняются.',
+1
View File
@@ -300,6 +300,7 @@ const uk = {
torrentPeerDiagnostics: 'Діагностика пірів торрента',
torrentPeerDiagnosticsRefresh: 'Оновити',
torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…',
torrentPeerDiagnosticsStale: 'Показано останній перевірений результат; оновіть, щоб перевірити ще раз.',
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний або призупинений.',
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
torrentPeerDiagnosticsHint: 'Перевірені адреси й порти пірів показуються лише тимчасово; ідентифікатори пірів і сирі бітові поля ніколи не зберігаються.',
+1
View File
@@ -300,6 +300,7 @@ const zhCN = {
torrentPeerDiagnostics: 'Torrent 对等节点诊断',
torrentPeerDiagnosticsRefresh: '刷新',
torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…',
torrentPeerDiagnosticsStale: '当前显示最近一次验证的结果;刷新以再次检查。',
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃或暂停时可查看对等节点诊断。',
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
torrentPeerDiagnosticsHint: '仅临时显示经过验证的对等端地址和端口;永不保留对等端 ID 或原始位域。',
+99 -15
View File
@@ -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);
}
+36
View File
@@ -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');
});
});
+41
View File
@@ -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,
);