fix(ui): harden download properties window

This commit is contained in:
NimBold
2026-08-06 07:08:29 +03:30
parent ee2448d084
commit 6f9a9e0638
15 changed files with 557 additions and 57 deletions
+170 -49
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { open, save } from '@tauri-apps/plugin-dialog';
import { Activity, Copy, Download, FileDown, FolderOpen, Gauge, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, Timer, Upload, Users, X } from 'lucide-react';
import { Activity, Copy, Download, FileDown, FileText, FolderOpen, Gauge, Info, List, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, SlidersHorizontal, Timer, Upload, Users, Wrench, X, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
import type { TorrentDetails } from '../bindings/TorrentDetails';
@@ -39,8 +39,10 @@ import {
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
import { changeAppLocale } from '../i18n';
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
import { getWindowControlRevealOffset } from '../utils/windowControlStyle';
import { getWindowControlRailWidth } from '../utils/windowControlStyle';
import { getPropertiesFooterActions } from '../utils/propertiesFooter';
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
import { WindowControls } from './WindowControls';
import {
TORRENT_ENCRYPTION_POLICY_DISABLED,
@@ -50,7 +52,6 @@ import {
type TorrentFileAllocation,
} from '../utils/downloads';
type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
type SecretName = 'username' | 'password' | 'cookies' | 'headers';
type SecretDraft = { value: string; touched: boolean; clear: boolean };
const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'];
@@ -83,6 +84,19 @@ const propertiesStatusTone = (status: string) => {
return 'downloading';
};
const propertiesTabIcon = (tab: PropertiesTab) => {
const props = { className: 'properties-window-tab-icon', size: 15, strokeWidth: 2, 'aria-hidden': true } as const;
switch (tab) {
case 'overview': return <Info {...props} />;
case 'files': return <FileText {...props} />;
case 'trackers': return <List {...props} />;
case 'peers': return <Users {...props} />;
case 'options': return <SlidersHorizontal {...props} />;
case 'transfer': return <Gauge {...props} />;
case 'advanced': return <Wrench {...props} />;
}
};
const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string => [
snapshot.id,
snapshot.status,
@@ -106,6 +120,9 @@ export const PropertiesWindowApp = () => {
const sessionId = useMemo(() => crypto.randomUUID(), []);
const [downloadId, setDownloadId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<PropertiesSnapshot | null>(null);
const [isUrlExpanded, setIsUrlExpanded] = useState(false);
const [urlHasOverflow, setUrlHasOverflow] = useState(false);
const [useTabOverflow, setUseTabOverflow] = useState(false);
const [activeTab, setActiveTab] = useState<PropertiesTab>('overview');
const [pendingTab, setPendingTab] = useState<PropertiesTab | null>(null);
const [closePrompt, setClosePrompt] = useState(false);
@@ -178,6 +195,10 @@ export const PropertiesWindowApp = () => {
const allowWindowCloseRef = useRef(false);
const isDirtyRef = useRef(false);
const windowChromeRef = useRef(DEFAULT_PROPERTIES_WINDOW_CHROME);
const previousUrlDownloadIdRef = useRef<string | null>(downloadId);
const previousUrlRef = useRef<string | null>(null);
const urlCardRef = useRef<HTMLDivElement | null>(null);
const urlValueRef = useRef<HTMLParagraphElement | null>(null);
snapshotRef.current = snapshot;
activeTabRef.current = activeTab;
downloadIdRef.current = downloadId;
@@ -187,9 +208,8 @@ export const PropertiesWindowApp = () => {
detailsRef.current = details;
const isTorrent = snapshot?.isTorrent === true;
const tabs = useMemo<PropertiesTab[]>(() => isTorrent
? ['overview', 'files', 'trackers', 'peers', 'options']
: ['overview', 'transfer', 'advanced'], [isTorrent]);
const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]);
const urlCanExpand = Boolean(snapshot && (shouldOfferPropertiesUrlExpansion(snapshot.url) || urlHasOverflow));
const isDirty = draftTab !== null;
isDirtyRef.current = isDirty;
if (isDirty && allowWindowCloseRef.current) {
@@ -199,6 +219,60 @@ export const PropertiesWindowApp = () => {
allowWindowCloseRef.current = false;
}
useEffect(() => {
const nextUrl = snapshot?.url ?? null;
if (shouldResetPropertiesUrlExpansion(
previousUrlDownloadIdRef.current,
downloadId,
previousUrlRef.current,
nextUrl,
)) {
setIsUrlExpanded(false);
setUrlHasOverflow(false);
}
previousUrlDownloadIdRef.current = downloadId;
previousUrlRef.current = nextUrl;
}, [downloadId, snapshot?.url]);
useEffect(() => {
const updateOverflowState = () => setUseTabOverflow(shouldUsePropertiesTabOverflow(window.innerWidth));
updateOverflowState();
const media = typeof window.matchMedia === 'function'
? window.matchMedia(`(max-width: ${PROPERTIES_TABS_OVERFLOW_BREAKPOINT}px)`)
: null;
if (media && typeof media.addEventListener === 'function') {
media.addEventListener('change', updateOverflowState);
return () => media.removeEventListener('change', updateOverflowState);
}
if (media && typeof media.addListener === 'function') {
media.addListener(updateOverflowState);
return () => media.removeListener(updateOverflowState);
}
window.addEventListener('resize', updateOverflowState);
return () => window.removeEventListener('resize', updateOverflowState);
}, []);
useLayoutEffect(() => {
if (isUrlExpanded) return;
const card = urlCardRef.current;
const value = urlValueRef.current;
if (!card || !value) {
setUrlHasOverflow(false);
return;
}
const measureOverflow = () => {
setUrlHasOverflow(value.scrollHeight > value.clientHeight + 1);
};
measureOverflow();
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(measureOverflow);
observer.observe(card);
return () => observer.disconnect();
}
window.addEventListener('resize', measureOverflow);
return () => window.removeEventListener('resize', measureOverflow);
}, [activeTab, isUrlExpanded, snapshot?.url, useTabOverflow]);
const closeCurrentWindow = useCallback(async (allowDirtyClose = false) => {
allowWindowCloseRef.current = allowDirtyClose;
try {
@@ -780,9 +854,16 @@ export const PropertiesWindowApp = () => {
}, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]);
const chooseTab = (tab: PropertiesTab) => {
if (tab === activeTab) return;
if (isDirty) setPendingTab(tab);
else setActiveTab(tab);
if (tab === activeTab) {
if (pendingTab !== null) setPendingTab(null);
return false;
}
if (isDirty) {
setPendingTab(tab);
return false;
}
setActiveTab(tab);
return true;
};
const discardDraft = () => {
@@ -838,12 +919,14 @@ export const PropertiesWindowApp = () => {
};
const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current;
const windowControlRevealOffset = getWindowControlRevealOffset(windowChrome.controlStyle);
const windowControlRailWidth = getWindowControlRailWidth(windowChrome.controlStyle);
const windowShellClassName = `properties-window-shell properties-window-shell--controls-${windowChrome.side} properties-window-shell--style-${windowChrome.controlStyle} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`;
const windowShellStyle = { '--properties-window-control-rail-width': `${windowControlRailWidth}px` } as CSSProperties;
if (!downloadId || !snapshot) {
return (
<main
className={`properties-window-shell properties-window-shell--controls-${windowChrome.side} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`}
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
className={windowShellClassName}
style={windowShellStyle}
aria-labelledby="properties-window-title"
>
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
@@ -891,20 +974,20 @@ export const PropertiesWindowApp = () => {
: t($ => $.downloads.actions.start);
const tabLabel = (tab: PropertiesTab) => {
switch (tab) {
case 'overview': return t($ => $.properties.details);
case 'files': return t($ => $.properties.torrentFileProgress);
case 'trackers': return t($ => $.properties.torrentTrackers);
case 'peers': return t($ => $.properties.torrentPeerDiagnostics);
case 'options': return t($ => $.downloads.actions.options);
case 'transfer': return t($ => $.properties.connections);
case 'advanced': return t($ => $.properties.advancedTransfer);
case 'overview': return t($ => $.properties.tabs.overview);
case 'files': return t($ => $.properties.tabs.files);
case 'trackers': return t($ => $.properties.tabs.trackers);
case 'peers': return t($ => $.properties.tabs.peers);
case 'options': return t($ => $.properties.tabs.options);
case 'transfer': return t($ => $.properties.tabs.transfer);
case 'advanced': return t($ => $.properties.tabs.advanced);
}
};
return (
<main
className={`properties-window-shell properties-window-shell--controls-${windowChrome.side} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`}
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
className={windowShellClassName}
style={windowShellStyle}
aria-labelledby="properties-window-title"
>
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
@@ -975,40 +1058,78 @@ export const PropertiesWindowApp = () => {
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
</header>
<nav className="properties-window-tabs flex shrink-0 gap-1 overflow-x-auto border-b border-border-modal px-4" role="tablist" aria-label={t($ => $.downloadTable.properties)}>
{tabs.map(tab => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
aria-controls={`properties-panel-${tab}`}
tabIndex={activeTab === tab ? 0 : -1}
className={`whitespace-nowrap border-b-2 px-3 py-2 text-xs font-medium ${activeTab === tab ? 'border-accent text-text-primary' : 'border-transparent text-text-muted hover:text-text-primary'}`}
onClick={() => chooseTab(tab)}
onKeyDown={event => {
const index = tabs.indexOf(tab);
const nextIndex = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index - 1 + tabs.length) % tabs.length : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : -1;
if (nextIndex >= 0) {
event.preventDefault();
const next = tabs[nextIndex];
chooseTab(next);
window.setTimeout(() => document.getElementById(`properties-tab-${next}`)?.focus(), 0);
}
}}
id={`properties-tab-${tab}`}
>{tabLabel(tab)}</button>
))}
</nav>
<div className={`properties-window-tab-navigation ${useTabOverflow ? 'properties-window-tab-navigation--overflow' : ''}`}>
<span id="properties-active-section-label" className="sr-only">{tabLabel(activeTab)}</span>
<nav className="properties-window-tabs" role="tablist" aria-label={t($ => $.properties.tabs.label)}>
{tabs.map(tab => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
aria-controls={`properties-panel-${tab}`}
tabIndex={(pendingTab ?? activeTab) === tab ? 0 : -1}
className="properties-window-tab"
onClick={() => chooseTab(tab)}
onKeyDown={event => {
const index = tabs.indexOf(tab);
const nextIndex = getPropertiesTabIndex(
tabs,
index,
event.key,
document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr',
);
if (nextIndex >= 0) {
event.preventDefault();
const next = tabs[nextIndex];
if (chooseTab(next) || isDirty) {
window.setTimeout(() => document.getElementById(`properties-tab-${next}`)?.focus(), 0);
}
}
}}
id={`properties-tab-${tab}`}
>
{propertiesTabIcon(tab)}
<span>{tabLabel(tab)}</span>
</button>
))}
</nav>
<label className="properties-window-tab-overflow">
<span className="sr-only">{t($ => $.properties.tabs.label)}</span>
<select
aria-label={t($ => $.properties.tabs.label)}
value={pendingTab ?? activeTab}
aria-controls={`properties-panel-${activeTab}`}
onChange={event => chooseTab(event.target.value as PropertiesTab)}
>
{tabs.map(tab => <option key={tab} value={tab}>{tabLabel(tab)}</option>)}
</select>
</label>
</div>
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={`properties-tab-${activeTab}`} className="properties-window-panel min-h-0 flex-1 overflow-auto p-5" data-diagnostic-phase={diagnosticPhase} tabIndex={0}>
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={useTabOverflow ? 'properties-active-section-label' : `properties-tab-${activeTab}`} className="properties-window-panel min-h-0 flex-1 overflow-auto p-5" data-diagnostic-phase={diagnosticPhase} tabIndex={0}>
{activeTab === 'overview' && <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
<label className="text-xs text-text-muted">{t($ => $.properties.destination)}<input className="app-control mt-1 w-full" value={destination} onChange={event => { setDestination(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
</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.url)}</span><p className="mt-1 break-all" dir="ltr">{snapshot.url}</p></div>
<div ref={urlCardRef} className="properties-url-card rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs">
<div className="properties-url-card-header">
<span className="text-text-muted">{t($ => $.properties.url)}</span>
{urlCanExpand && <button
type="button"
className="properties-url-toggle"
aria-expanded={isUrlExpanded}
aria-controls="properties-url-value"
onClick={() => setIsUrlExpanded(expanded => !expanded)}
>
{isUrlExpanded ? <ChevronUp size={14} aria-hidden="true" /> : <ChevronDown size={14} aria-hidden="true" />}
{isUrlExpanded ? t($ => $.properties.urlShowLess) : t($ => $.properties.urlShowMore)}
</button>}
</div>
<p ref={urlValueRef} id="properties-url-value" className={`properties-url-value mt-1 ${!isUrlExpanded ? 'properties-url-value--collapsed' : ''}`} dir="ltr">{snapshot.url}</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.category)}</span><p className="mt-1">{snapshot.category}</p></div>
</div>
<div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
+1
View File
@@ -22,6 +22,7 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) {
<div
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
aria-label={t($ => $.window.controls)}
role="group"
>
<button
type="button"
+12
View File
@@ -230,6 +230,16 @@ const common = {
configuredConcurrency: 'Configured concurrency',
connectedPeers: 'connected peers',
details: 'Details',
tabs: {
label: 'Properties sections',
overview: 'Overview',
files: 'Files',
trackers: 'Trackers',
peers: 'Peers',
transfer: 'Transfer',
options: 'Options',
advanced: 'Advanced',
},
queueId: 'Queue',
queuePosition: 'Position {{position}}',
resumable: 'Resumable',
@@ -406,6 +416,8 @@ const common = {
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
download: 'Download',
url: 'URL',
urlShowMore: 'Show full address',
urlShowLess: 'Show less',
fileName: 'File name',
saveLocation: 'Save location',
select: 'Select',
+12
View File
@@ -230,6 +230,16 @@ const fa = {
configuredConcurrency: 'هم‌زمانی پیکربندی‌شده',
connectedPeers: 'همتای متصل',
details: 'جزئیات',
tabs: {
label: 'بخش‌های ویژگی‌ها',
overview: 'نمای کلی',
files: 'فایل‌ها',
trackers: 'Trackerها',
peers: 'همتاها',
transfer: 'انتقال',
options: 'گزینه‌ها',
advanced: 'پیشرفته',
},
queueId: 'صف',
queuePosition: 'موقعیت {{position}}',
resumable: 'قابل ادامه',
@@ -406,6 +416,8 @@ const fa = {
transferSettings: 'تنظیمات انتقال را می‌توان پس از توقف تغییر داد. انتقال‌های کنونی گزینه‌های فعلی خود را حفظ می‌کنند.',
download: 'دانلود',
url: 'URL',
urlShowMore: 'نمایش نشانی کامل',
urlShowLess: 'نمایش کمتر',
fileName: 'نام فایل',
saveLocation: 'محل ذخیره',
select: 'انتخاب',
+12
View File
@@ -230,6 +230,16 @@ const he = {
configuredConcurrency: 'מקביליות מוגדרת',
connectedPeers: 'עמיתים מחוברים',
details: 'פרטים',
tabs: {
label: 'מקטעי המאפיינים',
overview: 'סקירה',
files: 'קבצים',
trackers: 'עוקבים',
peers: 'עמיתים',
transfer: 'העברה',
options: 'אפשרויות',
advanced: 'מתקדם',
},
queueId: 'תור',
queuePosition: 'מיקום {{position}}',
resumable: 'ניתן להמשך',
@@ -406,6 +416,8 @@ const he = {
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
download: 'הורדה',
url: 'URL',
urlShowMore: 'הצג כתובת מלאה',
urlShowLess: 'הצג פחות',
fileName: 'שם קובץ',
saveLocation: 'מיקום שמירה',
select: 'בחירה',
+12
View File
@@ -230,6 +230,16 @@ const ru = {
configuredConcurrency: 'Настроенная параллельность',
connectedPeers: 'подключённых пиров',
details: 'Подробности',
tabs: {
label: 'Разделы свойств',
overview: 'Обзор',
files: 'Файлы',
trackers: 'Трекеры',
peers: 'Пиры',
transfer: 'Передача',
options: 'Параметры',
advanced: 'Дополнительно',
},
queueId: 'Очередь',
queuePosition: 'Позиция {{position}}',
resumable: 'Возобновляемая',
@@ -406,6 +416,8 @@ const ru = {
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
download: 'Загрузка',
url: 'URL',
urlShowMore: 'Показать полный адрес',
urlShowLess: 'Свернуть',
fileName: 'Имя файла',
saveLocation: 'Место сохранения',
select: 'Выбрать',
+12
View File
@@ -230,6 +230,16 @@ const uk = {
configuredConcurrency: 'Налаштована паралельність',
connectedPeers: 'підключених пірів',
details: 'Деталі',
tabs: {
label: 'Розділи властивостей',
overview: 'Огляд',
files: 'Файли',
trackers: 'Трекери',
peers: 'Піри',
transfer: 'Передавання',
options: 'Параметри',
advanced: 'Додатково',
},
queueId: 'Черга',
queuePosition: 'Позиція {{position}}',
resumable: 'Можна продовжити',
@@ -406,6 +416,8 @@ const uk = {
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
download: 'Завантаження',
url: 'URL',
urlShowMore: 'Показати повну адресу',
urlShowLess: 'Згорнути',
fileName: 'Ім\'я файлу',
saveLocation: 'Місце збереження',
select: 'Вибрати',
+12
View File
@@ -230,6 +230,16 @@ const zhCN = {
configuredConcurrency: '已配置并发数',
connectedPeers: '已连接对等端',
details: '详细信息',
tabs: {
label: '属性部分',
overview: '概览',
files: '文件',
trackers: 'Tracker',
peers: '对等端',
transfer: '传输',
options: '选项',
advanced: '高级',
},
queueId: '队列',
queuePosition: '位置 {{position}}',
resumable: '可续传',
@@ -406,6 +416,8 @@ const zhCN = {
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
download: '下载',
url: 'URL',
urlShowMore: '显示完整地址',
urlShowLess: '收起',
fileName: '文件名',
saveLocation: '保存位置',
select: '选择',
+191 -8
View File
@@ -574,6 +574,7 @@ html[data-list-density="relaxed"] {
.properties-window-shell {
--properties-header-surface: hsl(var(--surface-raised));
--properties-card-surface: hsl(var(--bg-input) / 0.42);
position: relative;
min-height: 100%;
overflow: hidden;
border: 1px solid hsl(var(--border-color));
@@ -586,6 +587,7 @@ html[data-list-density="relaxed"] {
height: 52px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
padding: 0 18px;
direction: ltr;
border-bottom: 1px solid hsl(var(--border-color));
@@ -601,16 +603,12 @@ html[data-list-density="relaxed"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
white-space: nowrap;
}
.properties-window-shell--controls-left .properties-window-titlebar {
padding-left: calc(var(--window-control-reveal-offset, 88px) + 18px);
}
.properties-window-shell--controls-right .properties-window-titlebar {
justify-content: flex-end;
padding-right: calc(var(--window-control-reveal-offset, 88px) + 18px);
.properties-window-shell .properties-window-titlebar {
padding-inline: calc(var(--properties-window-control-rail-width, 60px) + 22px);
}
.properties-window-header {
@@ -850,6 +848,116 @@ html[data-list-density="relaxed"] {
white-space: nowrap;
}
.properties-window-tab-navigation {
display: flex;
min-width: 0;
flex: 0 0 auto;
align-items: center;
gap: 10px;
padding: 7px 16px;
border-bottom: 1px solid hsl(var(--border-modal));
background: hsl(var(--surface-raised));
}
.properties-window-tab-navigation--overflow .properties-window-tabs {
display: none;
}
.properties-window-tab-navigation--overflow .properties-window-tab-overflow {
display: flex;
}
.properties-window-tabs {
display: flex;
min-width: 0;
flex: 1 1 auto;
align-items: center;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
}
.properties-window-tabs::-webkit-scrollbar {
display: none;
}
.properties-window-tab {
display: inline-flex;
min-height: 38px;
flex: 0 0 auto;
align-items: center;
gap: 7px;
padding: 7px 12px;
border: 1px solid transparent;
border-radius: 9px;
color: hsl(var(--text-muted));
font-size: 12px;
font-weight: 650;
line-height: 1.2;
white-space: nowrap;
transition:
background-color 120ms ease,
border-color 120ms ease,
color 120ms ease,
transform 120ms ease;
}
.properties-window-tab:hover {
border-color: hsl(var(--border-modal));
background: hsl(var(--item-hover));
color: hsl(var(--text-primary));
}
.properties-window-tab[aria-selected="true"] {
border-color: hsl(var(--accent-color) / 0.34);
background: hsl(var(--accent-color) / 0.15);
color: hsl(var(--text-primary));
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.08);
}
.properties-window-tab:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.76);
outline-offset: 2px;
}
.properties-window-tab:active {
transform: scale(0.98);
}
.properties-window-tab-icon {
flex: 0 0 auto;
color: hsl(var(--accent-color));
}
.properties-window-tab-overflow {
display: none;
min-width: 0;
flex: 1 1 auto;
align-items: center;
gap: 8px;
color: hsl(var(--text-muted));
font-size: 12px;
font-weight: 650;
}
.properties-window-tab-overflow select {
min-width: 0;
flex: 1 1 auto;
min-height: 36px;
padding: 6px 10px;
border: 1px solid hsl(var(--border-modal));
border-radius: 8px;
background: hsl(var(--bg-input) / 0.55);
color: hsl(var(--text-primary));
font-size: 12px;
font-weight: 650;
}
.properties-window-tab-overflow select:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.76);
outline-offset: 2px;
}
.properties-window-panel {
scrollbar-gutter: stable;
}
@@ -901,6 +1009,55 @@ html[data-list-density="relaxed"] {
vertical-align: middle;
}
.properties-url-card {
min-width: 0;
}
.properties-url-card-header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.properties-url-toggle {
display: inline-flex;
min-height: 28px;
flex: 0 0 auto;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: 6px;
color: hsl(var(--accent-color));
font-size: 11px;
font-weight: 650;
}
.properties-url-toggle:hover {
background: hsl(var(--accent-color) / 0.1);
color: hsl(var(--text-primary));
}
.properties-url-toggle:focus-visible {
outline: 2px solid hsl(var(--accent-color) / 0.76);
outline-offset: 2px;
}
.properties-url-value {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.properties-url-value--collapsed {
display: -webkit-box;
max-height: calc(1.45em * 2);
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (max-width: 720px) {
.properties-window-hero-top {
align-items: stretch;
@@ -925,6 +1082,20 @@ html[data-list-density="relaxed"] {
}
}
@media (max-width: 620px) {
.properties-window-tab-navigation {
padding-inline: 12px;
}
.properties-window-tabs {
display: none;
}
.properties-window-tab-overflow {
display: flex;
}
}
@media (max-width: 440px) {
.properties-window-header {
padding-inline: 14px;
@@ -950,6 +1121,14 @@ html[data-list-density="relaxed"] {
.properties-window-progress-fill {
transition: none;
}
.properties-window-tab {
transition: none;
}
.properties-window-tab:active {
transform: none;
}
}
.app-icon-button {
@@ -2430,7 +2609,7 @@ html[data-list-density="relaxed"] {
}
.window-controls {
position: fixed;
position: absolute;
top: 20px;
left: 22px;
right: auto;
@@ -2443,6 +2622,10 @@ html[data-list-density="relaxed"] {
pointer-events: auto;
}
.app-shell {
position: relative;
}
.app-shell--sidebar-right .window-controls {
left: auto;
right: 22px;
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
getPropertiesTabIndex,
getPropertiesTabs,
PROPERTIES_TABS_OVERFLOW_BREAKPOINT,
shouldUsePropertiesTabOverflow,
} from './propertiesTabs';
describe('Properties tabs', () => {
it('keeps torrent and normal-download sections concise and stable', () => {
expect(getPropertiesTabs(false)).toEqual(['overview', 'transfer', 'advanced']);
expect(getPropertiesTabs(true)).toEqual(['overview', 'files', 'trackers', 'peers', 'options']);
});
it('uses the responsive overflow control at narrow widths', () => {
expect(shouldUsePropertiesTabOverflow(PROPERTIES_TABS_OVERFLOW_BREAKPOINT)).toBe(true);
expect(shouldUsePropertiesTabOverflow(PROPERTIES_TABS_OVERFLOW_BREAKPOINT + 1)).toBe(false);
expect(shouldUsePropertiesTabOverflow(Number.NaN)).toBe(false);
});
it('moves through tabs according to physical direction', () => {
const tabs = getPropertiesTabs(true);
expect(getPropertiesTabIndex(tabs, 0, 'ArrowRight', 'ltr')).toBe(1);
expect(getPropertiesTabIndex(tabs, 0, 'ArrowLeft', 'ltr')).toBe(tabs.length - 1);
expect(getPropertiesTabIndex(tabs, 0, 'ArrowRight', 'rtl')).toBe(tabs.length - 1);
expect(getPropertiesTabIndex(tabs, 0, 'ArrowLeft', 'rtl')).toBe(1);
expect(getPropertiesTabIndex(tabs, 2, 'Home', 'ltr')).toBe(0);
expect(getPropertiesTabIndex(tabs, 2, 'End', 'ltr')).toBe(tabs.length - 1);
});
});
+40
View File
@@ -0,0 +1,40 @@
export type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
const TORRENT_PROPERTIES_TABS: readonly PropertiesTab[] = [
'overview',
'files',
'trackers',
'peers',
'options',
];
const DOWNLOAD_PROPERTIES_TABS: readonly PropertiesTab[] = [
'overview',
'transfer',
'advanced',
];
export const PROPERTIES_TABS_OVERFLOW_BREAKPOINT = 620;
export const getPropertiesTabs = (isTorrent: boolean): readonly PropertiesTab[] =>
isTorrent ? TORRENT_PROPERTIES_TABS : DOWNLOAD_PROPERTIES_TABS;
export const shouldUsePropertiesTabOverflow = (width: number): boolean =>
Number.isFinite(width) && width <= PROPERTIES_TABS_OVERFLOW_BREAKPOINT;
export const getPropertiesTabIndex = (
tabs: readonly PropertiesTab[],
currentIndex: number,
key: string,
direction: 'ltr' | 'rtl',
): number => {
if (tabs.length === 0) return -1;
if (key === 'Home') return 0;
if (key === 'End') return tabs.length - 1;
if (key !== 'ArrowLeft' && key !== 'ArrowRight') return -1;
const step = key === 'ArrowRight'
? (direction === 'rtl' ? -1 : 1)
: (direction === 'rtl' ? 1 : -1);
return (currentIndex + step + tabs.length) % tabs.length;
};
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import {
PROPERTIES_URL_PREVIEW_MAX_LENGTH,
shouldOfferPropertiesUrlExpansion,
shouldResetPropertiesUrlExpansion,
} from './propertiesUrl';
describe('Properties URL disclosure', () => {
it('offers expansion only for URLs longer than the preview budget', () => {
expect(shouldOfferPropertiesUrlExpansion('x'.repeat(PROPERTIES_URL_PREVIEW_MAX_LENGTH))).toBe(false);
expect(shouldOfferPropertiesUrlExpansion('x'.repeat(PROPERTIES_URL_PREVIEW_MAX_LENGTH + 1))).toBe(true);
});
it('resets expansion when the displayed download changes', () => {
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-1')).toBe(false);
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-2')).toBe(true);
expect(shouldResetPropertiesUrlExpansion(null, 'download-1')).toBe(true);
});
it('resets expansion when the displayed address changes', () => {
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-1', 'magnet:?xt=old', 'magnet:?xt=old')).toBe(false);
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-1', 'magnet:?xt=old', 'magnet:?xt=new')).toBe(true);
});
});
+11
View File
@@ -0,0 +1,11 @@
export const PROPERTIES_URL_PREVIEW_MAX_LENGTH = 180;
export const shouldOfferPropertiesUrlExpansion = (url: string): boolean =>
url.length > PROPERTIES_URL_PREVIEW_MAX_LENGTH;
export const shouldResetPropertiesUrlExpansion = (
previousDownloadId: string | null,
nextDownloadId: string | null,
previousUrl?: string | null,
nextUrl?: string | null,
): boolean => previousDownloadId !== nextDownloadId || previousUrl !== nextUrl;
+8
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
getWindowControlRailWidth,
getWindowControlRevealOffset,
resolveWindowControlSide,
resolveWindowControlStyle,
@@ -37,6 +38,13 @@ describe('resolveWindowControlStyle', () => {
expect(getWindowControlRevealOffset('minimal')).toBe(104);
});
it('reports the shared titlebar rail width for each control style', () => {
expect(getWindowControlRailWidth('macos')).toBe(60);
expect(getWindowControlRailWidth('windows')).toBe(138);
expect(getWindowControlRailWidth('gnome')).toBe(104);
expect(getWindowControlRailWidth('minimal')).toBe(74);
});
it('resolves automatic control placement from the effective document direction', () => {
expect(resolveWindowControlSide('auto', 'ltr')).toBe('left');
expect(resolveWindowControlSide('auto', 'rtl')).toBe('right');
+10
View File
@@ -14,9 +14,19 @@ const WINDOW_CONTROL_REVEAL_OFFSETS: Record<ResolvedWindowControlStyle, number>
minimal: 104,
};
const WINDOW_CONTROL_RAIL_WIDTHS: Record<ResolvedWindowControlStyle, number> = {
macos: 60,
windows: 138,
gnome: 104,
minimal: 74,
};
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
WINDOW_CONTROL_REVEAL_OFFSETS[style];
export const getWindowControlRailWidth = (style: ResolvedWindowControlStyle): number =>
WINDOW_CONTROL_RAIL_WIDTHS[style];
export const resolveWindowControlSide = (
sidebarPosition: SidebarPosition,
direction: 'ltr' | 'rtl',