import type { MouseEvent } from 'react'; import type { Torrent } from './shared'; import type { ColumnDef, ColumnKey } from './columns'; import type { SortKey } from './useTorrentFilters'; import { useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ArrowDown, ArrowUp, Check } from 'lucide-react'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import { ALL_COLUMNS, PINNED_COLUMN } from './columns'; // The torrent list. Virtualised because the row count is unbounded and the whole thing re-renders on a // 5-second poll — 43 rows is fine, 4000 is not, and a torrent client accumulates. // // Layout is a fixed-width grid inside a single horizontal scroller shared by the header and the body, so // the two cannot drift apart the way two independently-scrolled elements do. const ROW_HEIGHT = 30; type TorrentTableProps = { torrents: Torrent[]; columns: ColumnDef[]; /** Multi-selection, for the bulk actions in the toolbar and the row context menu. */ selection: Set; /** The one torrent whose detail pane is open (?selected=) — a different thing from being in `selection`. */ focused: number | null; onRowClick: (torrent: Torrent, ev: MouseEvent) => void; sort: SortKey; desc: boolean; onSort: (key: SortKey) => void; visibleKeys: ColumnKey[]; onToggleColumn: (key: ColumnKey) => void; onResetColumns: () => void; /** Right-clicking a row selects it if it isn't already, then opens the actions menu. */ onRowContextMenu: (torrent: Torrent) => void; rowMenu: (torrent: Torrent) => React.ReactNode; }; export const TorrentTable = ({ torrents, columns, selection, focused, onRowClick, sort, desc, onSort, visibleKeys, onToggleColumn, onResetColumns, onRowContextMenu, rowMenu, }: TorrentTableProps) => { const scrollRef = useRef(null); const virtualizer = useVirtualizer({ count: torrents.length, getScrollElement: () => scrollRef.current, estimateSize: () => ROW_HEIGHT, overscan: 12, }); const gridTemplate = columns.map((c) => `${c.width}px`).join(' '); const totalWidth = columns.reduce((sum, c) => sum + c.width, 0); return (
{/* min-w on an inline-block wrapper: the header must be as wide as the widest row so it keeps scrolling with the body rather than clipping at the viewport edge. */}
{columns.map((col) => ( ))}
{ALL_COLUMNS.map((col) => { const shown = visibleKeys.includes(col.key); const pinned = col.key === PINNED_COLUMN; return ( { // Keep the menu open — picking columns is a several-clicks job and reopening it each // time, at the same mouse position, is a small torture. ev.preventDefault(); onToggleColumn(col.key); }} > {col.label} ); })} Reset to defaults
{virtualizer.getVirtualItems().map((row) => { const torrent = torrents[row.index]; if (!torrent) return null; const isSelected = selection.has(torrent.id); const isFocused = focused === torrent.id; return (
onRowClick(torrent, ev)} onContextMenu={() => onRowContextMenu(torrent)} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: row.size, transform: `translateY(${row.start}px)`, gridTemplateColumns: gridTemplate, }} className={`grid cursor-default items-center border-b border-border/40 text-xs ${ isSelected ? 'bg-primary/10' : 'hover:bg-muted/50' } ${isFocused ? 'ring-1 ring-inset ring-primary/40' : ''}`} > {columns.map((col) => (
{col.render(torrent)}
))}
{rowMenu(torrent)}
); })}
); };