import React from 'react'; import { Box, Text } from 'ink'; import { Torrent, TorrentState } from '../../engine/types.js'; import { ProgressBar } from './ProgressBar.js'; import { LabelList } from './LabelBadge.js'; import { colors, statusColors, borders } from '../theme/index.js'; export interface TorrentRowProps { /** The torrent to display */ torrent: Torrent; /** Whether this row is currently selected */ isSelected: boolean; /** The index number to display (1-based) */ index: number; /** Terminal width in columns for responsive layout */ width?: number; } /** * Calculate column widths based on terminal width * Columns scale down for smaller terminals */ export function calculateColumnWidths(terminalWidth: number) { // 8 borders: 2 outer (left/right) + 6 internal separators (7 columns) const borders = 8; const availableWidth = terminalWidth - borders; // For very small terminals, use minimal widths if (availableWidth < 100) { const progress = 12; const size = 8; const speed = 8; const seeds = 10; const peers = 10; const status = 10; const name = Math.max( 10, availableWidth - progress - size - speed - seeds - peers - status ); return { name, progress, size, speed, seeds, peers, status }; } // For medium terminals if (availableWidth < 140) { const progress = 16; const size = 10; const speed = 10; const seeds = 10; const peers = 10; const status = 12; const name = availableWidth - progress - size - speed - seeds - peers - status; return { name, progress, size, speed, seeds, peers, status }; } // For large terminals const progress = 20; const size = 12; const speed = 12; const seeds = 12; const peers = 12; const status = 14; const name = availableWidth - progress - size - speed - seeds - peers - status; return { name, progress, size, speed, seeds, peers, status }; } /** * Status display text for each torrent state */ const STATUS_TEXT: Record = { [TorrentState.DOWNLOADING]: 'Downloading', [TorrentState.SEEDING]: 'Seeding', [TorrentState.PAUSED]: 'Paused', [TorrentState.ERROR]: 'Error', [TorrentState.CHECKING]: 'Checking', [TorrentState.QUEUED]: 'Queued', }; /** * Colors for each torrent state */ const STATUS_COLORS: Record = { [TorrentState.DOWNLOADING]: statusColors.downloading, [TorrentState.SEEDING]: statusColors.seeding, [TorrentState.PAUSED]: statusColors.paused, [TorrentState.ERROR]: statusColors.error, [TorrentState.CHECKING]: statusColors.checking, [TorrentState.QUEUED]: statusColors.queued, }; /** * Format bytes into human-readable size string */ function formatSize(bytes: number): string { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const size = bytes / Math.pow(1024, i); if (size >= 100) { return `${Math.round(size)} ${units[i]}`; } else if (size >= 10) { return `${size.toFixed(1)} ${units[i]}`; } else { return `${size.toFixed(2)} ${units[i]}`; } } /** * Format speed in bytes/second to human-readable string */ function formatSpeed(bytesPerSecond: number): string { if (bytesPerSecond === 0) return '--'; const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; const i = Math.floor(Math.log(bytesPerSecond) / Math.log(1024)); const speed = bytesPerSecond / Math.pow(1024, i); if (speed >= 10) { return `${Math.round(speed)} ${units[i]}`; } else { return `${speed.toFixed(1)} ${units[i]}`; } } /** * Truncate a string to a maximum length, adding ellipsis if needed */ function truncateName(name: string, maxLength: number): string { if (name.length <= maxLength) { return name.padEnd(maxLength); } return name.slice(0, maxLength - 1) + '\u2026'; } /** * Get the relevant speed based on torrent state */ function getRelevantSpeed(torrent: Torrent): number { if (torrent.state === TorrentState.SEEDING) { return torrent.uploadSpeed; } return torrent.downloadSpeed; } /** * Get total seeds available from all trackers (max value from any tracker) */ function getTotalSeeds(torrent: Torrent): number { if (!torrent.trackers || torrent.trackers.length === 0) { return 0; } return Math.max(0, ...torrent.trackers.map((t) => t.seeds || 0)); } /** * Get total leeches (peers) available from all trackers (max value from any tracker) */ function getTotalLeeches(torrent: Torrent): number { if (!torrent.trackers || torrent.trackers.length === 0) { return 0; } return Math.max(0, ...torrent.trackers.map((t) => t.leeches || 0)); } /** * Format connected (available) display for seeds/peers * Handles undefined values by defaulting to 0 */ function formatPeerCount( connected: number | undefined, available: number ): string { return `${connected ?? 0} (${available})`; } /** * Single torrent display row component * * Column order: Name | Progress | Size | Speed | Status */ export const TorrentRow: React.FC = ({ torrent, isSelected, index: _index, width = 80, }) => { const statusText = STATUS_TEXT[torrent.state]; const statusColor = STATUS_COLORS[torrent.state]; const speed = getRelevantSpeed(torrent); const cols = calculateColumnWidths(width); // Progress bar width - use most of the progress column (leave room for " " + " 100%") const progressBarWidth = Math.max(4, cols.progress - 8); // Selected row uses green highlighting instead of inverse const selectedColor = colors.highlight; return ( {/* Left border */} {borders.vertical} {/* Name column */} {' '} {torrent.state === TorrentState.PAUSED && ( ⏸{' '} )} {truncateName( torrent.name, torrent.state === TorrentState.PAUSED ? cols.name - 5 : cols.name - 2 )}{' '} {borders.vertical} {/* Progress column */} {isSelected ? ( {'\u2588'.repeat(Math.round(torrent.progress * progressBarWidth))} {'\u2591'.repeat( progressBarWidth - Math.round(torrent.progress * progressBarWidth) )}{' '} {(torrent.progress >= 1 ? 100 : Math.floor(torrent.progress * 100)) .toString() .padStart(3)} % ) : ( )} {borders.vertical} {/* Size column */} {formatSize(torrent.size).padStart(cols.size - 2)}{' '} {borders.vertical} {/* Speed column */} {formatSpeed(speed).padStart(cols.speed - 2)}{' '} {borders.vertical} {/* Seeds column */} {formatPeerCount(torrent.seeds, getTotalSeeds(torrent)).padStart( cols.seeds - 2 )}{' '} {borders.vertical} {/* Peers column */} {formatPeerCount(torrent.peers, getTotalLeeches(torrent)).padStart( cols.peers - 2 )}{' '} {borders.vertical} {/* Status column */} {' '} {statusText.slice(0, cols.status - 2).padEnd(cols.status - 2)} {/* Right border */} {borders.vertical} {/* Labels (outside table) */} {torrent.labels && torrent.labels.length > 0 && ( )} ); }; export default TorrentRow;