import type { MouseEvent } from 'react'; import type { Torrent, TorrentAction } from './shared'; import type { ColumnKey } from './columns'; import { useCallback, useMemo, useRef, useState } from 'react'; import { Loader2, PackageOpen } from 'lucide-react'; import { useLocalStorageState } from 'hooks'; import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; import { COLUMN_BY_KEY, DEFAULT_VISIBLE_COLUMNS, PINNED_COLUMN } from './columns'; import { buildFacets, selectTorrents, useTorrentFilters } from './useTorrentFilters'; import { useSession, useTorrentMutations, useTorrents } from './useTransmissionData'; import { TorrentToolbar } from './TorrentToolbar'; import { TorrentTable } from './TorrentTable'; import { TorrentDetail } from './TorrentDetail'; import { AddTorrentDialog } from './dialogs/AddTorrentDialog'; import { RemoveTorrentDialog } from './dialogs/RemoveTorrentDialog'; import { MoveTorrentDialog } from './dialogs/MoveTorrentDialog'; import { LabelsDialog } from './dialogs/LabelsDialog'; import { TorrentOptionsDialog } from './dialogs/TorrentOptionsDialog'; // The torrents section: toolbar, list, and the detail pane for ?selected=. // // Two kinds of selection live here and they are deliberately different things: // • the multi-selection (local state) is what the toolbar and the context menu act on. It is ephemeral — // a checkbox state, not an address, and putting forty ids in the query string would be absurd. // • ?selected= is the one torrent whose detail pane is open. That IS an address: it survives a reload // and can be sent to yourself. const COLUMNS_STORAGE_KEY = 'transmission:columns'; /** How much of the pane the detail takes when open. */ const DETAIL_HEIGHT = '45%'; export const TorrentsView = () => { const { torrents, isLoading, error } = useTorrents(); const { session, save } = useSession(); const { act } = useTorrentMutations(); const { filters, setSearch, setSort, selected, setSelected } = useTorrentFilters(); const [visibleKeys, setVisibleKeys] = useLocalStorageState(COLUMNS_STORAGE_KEY, DEFAULT_VISIBLE_COLUMNS); const [selection, setSelection] = useState>(() => new Set()); const anchorRef = useRef(null); const [adding, setAdding] = useState(false); const [removing, setRemoving] = useState(false); const [moving, setMoving] = useState(false); const [labelling, setLabelling] = useState(false); const [optioning, setOptioning] = useState(false); const rows = useMemo(() => selectTorrents(torrents, filters), [torrents, filters]); const facets = useMemo(() => buildFacets(torrents), [torrents]); const columns = useMemo( () => // Ordered by the catalogue, not by the stored array — a saved order from an older column set would // otherwise silently drop the columns added since. [...visibleKeys].map((key) => COLUMN_BY_KEY.get(key)).filter((c): c is NonNullable => !!c), [visibleKeys], ); const byId = useMemo(() => new Map(torrents.map((t) => [t.id, t])), [torrents]); const selectedTorrents = useMemo( () => [...selection].map((id) => byId.get(id)).filter((t): t is Torrent => !!t), [selection, byId], ); const onRowClick = useCallback( (torrent: Torrent, ev: MouseEvent) => { const index = rows.findIndex((r) => r.id === torrent.id); if (ev.shiftKey && anchorRef.current != null) { const anchorIndex = rows.findIndex((r) => r.id === anchorRef.current); if (anchorIndex >= 0) { const [from, to] = anchorIndex < index ? [anchorIndex, index] : [index, anchorIndex]; setSelection(new Set(rows.slice(from, to + 1).map((r) => r.id))); return; } } if (ev.ctrlKey || ev.metaKey) { setSelection((prev) => { const next = new Set(prev); if (next.has(torrent.id)) next.delete(torrent.id); else next.add(torrent.id); return next; }); anchorRef.current = torrent.id; return; } setSelection(new Set([torrent.id])); anchorRef.current = torrent.id; // A plain click both selects and opens — the detail pane is the point of clicking a row. setSelected(torrent.id); }, [rows, setSelected], ); const onRowContextMenu = useCallback((torrent: Torrent) => { // Right-clicking outside the selection replaces it. Right-clicking inside keeps it, so a menu action on // a 20-row selection doesn't silently collapse to the one row under the cursor. setSelection((prev) => (prev.has(torrent.id) ? prev : new Set([torrent.id]))); anchorRef.current = torrent.id; }, []); const runAction = useCallback( (action: TorrentAction) => { if (selection.size === 0) return; act.mutate({ ids: [...selection], action }); }, [act, selection], ); const toggleColumn = useCallback( (key: ColumnKey) => { if (key === PINNED_COLUMN) return; setVisibleKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); }, [setVisibleKeys], ); const altSpeed = session?.['alt-speed-enabled'] === true; if (isLoading && torrents.length === 0) { return (
Loading torrents…
); } if (error) { return (
Cannot reach Transmission

The officer-transmission sidecar answered with an error. Check that the daemon is running, and that the URL under Connection still points at it.

); } return (
setAdding(true)} onRemove={() => selection.size > 0 && setRemoving(true)} onMove={() => selection.size > 0 && setMoving(true)} onLabels={() => selection.size > 0 && setLabelling(true)} onOptions={() => selection.size > 0 && setOptioning(true)} altSpeed={altSpeed} onToggleAltSpeed={() => save.mutate({ 'alt-speed-enabled': !altSpeed })} total={torrents.length} shown={rows.length} />
{rows.length === 0 ? (
{filters.isFiltered ? 'Nothing matches' : 'No torrents'}

{filters.isFiltered ? 'Clear the filters in the sidebar to see everything again.' : 'Add a magnet link or a .torrent file to get started.'}

) : ( setSort(key, filters.sort, filters.desc)} visibleKeys={visibleKeys} onToggleColumn={toggleColumn} onResetColumns={() => setVisibleKeys(DEFAULT_VISIBLE_COLUMNS)} onRowContextMenu={onRowContextMenu} rowMenu={() => ( <> runAction('start')}>Start runAction('start-now')}>Start now runAction('stop')}>Pause runAction('verify')}>Verify local data runAction('reannounce')}>Ask for more peers runAction('queue-top')}>Move to top of queue runAction('queue-up')}>Move up runAction('queue-down')}>Move down runAction('queue-bottom')}>Move to bottom setMoving(true)}>Set location… setLabelling(true)}>Labels… setOptioning(true)}>Torrent options… setRemoving(true)}> Remove… )} /> )}
{selected != null && (
setSelected(null)} />
)} { setSelection(new Set()); // The detail pane would keep polling a torrent that no longer exists and settle on "that torrent // is gone" — closing it is both correct and less alarming. if (selected != null && selection.has(selected)) setSelected(null); }} /> d.value)} /> l.value)} />
); };