diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 0eb7aca..30169e7 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -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 ; + case 'files': return ; + case 'trackers': return ; + case 'peers': return ; + case 'options': return ; + case 'transfer': return ; + case 'advanced': return ; + } +}; + 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(null); const [snapshot, setSnapshot] = useState(null); + const [isUrlExpanded, setIsUrlExpanded] = useState(false); + const [urlHasOverflow, setUrlHasOverflow] = useState(false); + const [useTabOverflow, setUseTabOverflow] = useState(false); const [activeTab, setActiveTab] = useState('overview'); const [pendingTab, setPendingTab] = useState(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(downloadId); + const previousUrlRef = useRef(null); + const urlCardRef = useRef(null); + const urlValueRef = useRef(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(() => 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 (
@@ -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 (
@@ -975,40 +1058,78 @@ export const PropertiesWindowApp = () => {
{snapshot.destination || '—'}
- +
+ {tabLabel(activeTab)} + + +
-
+
{activeTab === 'overview' &&
-
{t($ => $.properties.url)}

{snapshot.url}

+
+
+ {t($ => $.properties.url)} + {urlCanExpand && } +
+

{snapshot.url}

+
{t($ => $.properties.category)}

{snapshot.category}

diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index 6d382a6..748e688 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -22,6 +22,7 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) {
$.window.controls)} + role="group" >