import type { ReactNode } from 'react'; import type { Torrent } from './shared'; import type { SortKey } from './useTorrentFilters'; import { Lock } from 'lucide-react'; import { formatDate, formatDuration, formatEta, formatPercent, formatRatio, formatSize, formatSpeed, PRIORITY_LABELS, statusLabel, statusTone, TONE_CLASSES, } from './format'; // Column catalogue for the torrent table. Mirrors the reference client's set, minus the columns that only // exist there because its canvas renderer needed them split (uploadedDownloaded, the separate seeds cell). // // Widths are fixed px rather than fractions: the table scrolls horizontally and is virtualised vertically, // so a row has to be laid out without measuring, and a percentage-width grid inside a horizontal scroller // collapses to the viewport instead of the content. export type ColumnKey = | 'name' | 'status' | 'percentDone' | 'totalSize' | 'sizeWhenDone' | 'leftUntilDone' | 'haveValid' | 'downloadedEver' | 'uploadedEver' | 'rateDownload' | 'rateUpload' | 'eta' | 'uploadRatio' | 'peersSendingToUs' | 'peersGettingFromUs' | 'addedDate' | 'doneDate' | 'activityDate' | 'secondsSeeding' | 'downloadDir' | 'officerTrackerHost' | 'officerTrackerStatus' | 'bandwidthPriority' | 'queuePosition' | 'labels' | 'isPrivate' | 'group' | 'file-count' | 'pieceCount' | 'id'; export type ColumnDef = { key: ColumnKey; label: string; width: number; align?: 'right' | 'center'; /** Absent when the column has no meaningful order (nothing here, currently — kept for honesty). */ sortKey?: SortKey; render: (t: Torrent) => ReactNode; }; const num = (v: number | undefined) => (v == null ? '—' : v.toLocaleString()); /** A count of zero is noise in a dense table — the absence of peers is already visible as a blank. */ const positive = (v: number | undefined) => (v ? v.toLocaleString() : ''); const ProgressCell = ({ torrent }: { torrent: Torrent }) => { const tone = TONE_CLASSES[statusTone(torrent)]; // Verifying reports its own progress in recheckProgress; percentDone stays where it was, so a recheck of // a complete torrent would otherwise render a full bar while the daemon is 3% into the hash check. const isChecking = torrent.recheckProgress != null && torrent.recheckProgress > 0 && torrent.percentDone < 1; const fraction = isChecking ? torrent.recheckProgress! : torrent.percentDone; return (
{formatPercent(fraction)}
); }; export const ALL_COLUMNS: ColumnDef[] = [ { key: 'name', label: 'Name', width: 340, sortKey: 'name', render: (t) => ( {t.isPrivate && } {t.name} ), }, { key: 'status', label: 'Status', width: 130, sortKey: 'status', render: (t) => { const tone = TONE_CLASSES[statusTone(t)]; return ( {statusLabel(t)} ); }, }, { key: 'percentDone', label: 'Progress', width: 150, sortKey: 'percentDone', render: (t) => , }, { key: 'totalSize', label: 'Size', width: 90, align: 'right', sortKey: 'totalSize', render: (t) => formatSize(t.totalSize), }, { key: 'sizeWhenDone', label: 'Size when done', width: 120, align: 'right', render: (t) => formatSize(t.sizeWhenDone), }, { key: 'leftUntilDone', label: 'Remaining', width: 100, align: 'right', render: (t) => formatSize(t.leftUntilDone) }, { key: 'haveValid', label: 'Have', width: 90, align: 'right', render: (t) => formatSize(t.haveValid) }, { key: 'downloadedEver', label: 'Downloaded', width: 105, align: 'right', sortKey: 'downloadedEver', render: (t) => formatSize(t.downloadedEver), }, { key: 'uploadedEver', label: 'Uploaded', width: 100, align: 'right', sortKey: 'uploadedEver', render: (t) => formatSize(t.uploadedEver), }, { key: 'rateDownload', label: 'Down', width: 95, align: 'right', sortKey: 'rateDownload', render: (t) => {formatSpeed(t.rateDownload)}, }, { key: 'rateUpload', label: 'Up', width: 95, align: 'right', sortKey: 'rateUpload', render: (t) => {formatSpeed(t.rateUpload)}, }, { key: 'eta', label: 'ETA', width: 85, align: 'right', sortKey: 'eta', // ETA is meaningless once a torrent is done — Transmission keeps returning the sentinel and the column // would read "∞" down the whole seeding half of the list. render: (t) => (t.percentDone >= 1 ? '' : formatEta(t.eta)), }, { key: 'uploadRatio', label: 'Ratio', width: 75, align: 'right', sortKey: 'uploadRatio', render: (t) => formatRatio(t.uploadRatio), }, { key: 'peersSendingToUs', label: 'Seeds', width: 70, align: 'right', sortKey: 'peersSendingToUs', render: (t) => positive(t.peersSendingToUs), }, { key: 'peersGettingFromUs', label: 'Peers', width: 70, align: 'right', sortKey: 'peersGettingFromUs', render: (t) => positive(t.peersGettingFromUs), }, { key: 'addedDate', label: 'Added', width: 145, sortKey: 'addedDate', render: (t) => formatDate(t.addedDate) }, { key: 'doneDate', label: 'Completed', width: 145, sortKey: 'doneDate', render: (t) => formatDate(t.doneDate) }, { key: 'activityDate', label: 'Last active', width: 145, sortKey: 'activityDate', render: (t) => formatDate(t.activityDate), }, { key: 'secondsSeeding', label: 'Seeding time', width: 110, align: 'right', sortKey: 'secondsSeeding', render: (t) => formatDuration(t.secondsSeeding), }, { key: 'downloadDir', label: 'Location', width: 220, sortKey: 'downloadDir', render: (t) => ( {t.downloadDir} ), }, { key: 'officerTrackerHost', label: 'Tracker', width: 150, sortKey: 'officerTrackerHost', render: (t) => t.officerTrackerHost || '—', }, { key: 'officerTrackerStatus', label: 'Tracker status', width: 190, render: (t) => ( 0 ? 'text-warning' : undefined} title={t.officerTrackerStatus}> {t.officerTrackerStatus || '—'} ), }, { key: 'bandwidthPriority', label: 'Priority', width: 85, render: (t) => PRIORITY_LABELS[t.bandwidthPriority] ?? '—', }, { key: 'queuePosition', label: 'Queue', width: 70, align: 'right', sortKey: 'queuePosition', render: (t) => num(t.queuePosition), }, { key: 'labels', label: 'Labels', width: 160, render: (t) => t.labels.length ? ( {t.labels.map((l) => ( {l} ))} ) : ( '' ), }, { key: 'isPrivate', label: 'Private', width: 70, align: 'center', render: (t) => (t.isPrivate ? 'Yes' : 'No') }, { key: 'group', label: 'Group', width: 100, render: (t) => t.group || '' }, { key: 'file-count', label: 'Files', width: 70, align: 'right', render: (t) => num(t['file-count']) }, { key: 'pieceCount', label: 'Pieces', width: 80, align: 'right', render: (t) => num(t.pieceCount) }, { key: 'id', label: 'ID', width: 60, align: 'right', sortKey: 'id', render: (t) => String(t.id) }, ]; export const COLUMN_BY_KEY = new Map(ALL_COLUMNS.map((c) => [c.key, c])); /** What a fresh install shows. Everything else is one right-click on the header away. */ export const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = [ 'name', 'status', 'percentDone', 'totalSize', 'rateDownload', 'rateUpload', 'eta', 'uploadRatio', 'peersSendingToUs', 'peersGettingFromUs', 'addedDate', ]; /** The name column is the only thing identifying a row, so it is not hideable. */ export const PINNED_COLUMN: ColumnKey = 'name';