import type { Torrent } from '../shared'; import { useMemo, useState } from 'react'; import { ChevronRight, File, Folder, Pencil } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { PRIORITY } from '../shared'; import { formatPercent, formatSize, PRIORITY_LABELS } from '../format'; import { useTorrentMutations } from '../useTransmissionData'; import { RenameDialog } from '../dialogs/RenameDialog'; // Files, as a tree, because a torrent of a TV season is thirty files in three folders and a flat list of // thirty identical-looking paths is unusable. // // Transmission addresses files by their INDEX in the `files` array, not by path โ€” so every node carries the // indices beneath it, and a folder operation is just the same RPC with more indices. type FileNode = { key: string; name: string; /** Leaf files only; a directory's own index list is the union of its descendants'. */ index?: number; children: FileNode[]; indices: number[]; size: number; completed: number; }; function buildTree(torrent: Torrent): FileNode { const root: FileNode = { key: '', name: '', children: [], indices: [], size: 0, completed: 0 }; const files = torrent.files ?? []; files.forEach((file, index) => { const segments = file.name.split('/').filter(Boolean); let node = root; segments.forEach((segment, depth) => { const isLeaf = depth === segments.length - 1; const key = segments.slice(0, depth + 1).join('/'); let child = node.children.find((c) => c.key === key); if (!child) { child = { key, name: segment, children: [], indices: [], size: 0, completed: 0 }; node.children.push(child); } if (isLeaf) child.index = index; node = child; }); }); // Roll sizes and indices up in one post-order pass, so a folder row can show its own progress bar. const roll = (node: FileNode): FileNode => { if (node.index != null) { const file = files[node.index]!; node.size = file.length; node.completed = file.bytesCompleted; node.indices = [node.index]; return node; } node.children.forEach(roll); node.size = node.children.reduce((s, c) => s + c.size, 0); node.completed = node.children.reduce((s, c) => s + c.completed, 0); node.indices = node.children.flatMap((c) => c.indices); // Folders before files, then alphabetical โ€” the ordering every file manager uses. node.children.sort((a, b) => Number(!!a.index) - Number(!!b.index) || a.name.localeCompare(b.name)); return node; }; return roll(root); } type FilesTabProps = { torrent: Torrent }; export const FilesTab = ({ torrent }: FilesTabProps) => { const { set } = useTorrentMutations(); const [collapsed, setCollapsed] = useState>(new Set()); const [renaming, setRenaming] = useState(null); const tree = useMemo(() => buildTree(torrent), [torrent.files, torrent.id]); const stats = torrent.fileStats ?? []; if (!torrent.files?.length) { return (

{torrent.metadataPercentComplete < 1 ? 'Still fetching metadata โ€” the file list appears once the magnet resolves.' : 'This torrent has no files.'}

); } const wantedCount = stats.filter((s) => s.wanted).length; const wantedBytes = torrent.files.reduce((sum, f, i) => sum + (stats[i]?.wanted === false ? 0 : f.length), 0); const setWanted = (indices: number[], wanted: boolean) => set.mutate({ ids: [torrent.id], [wanted ? 'files-wanted' : 'files-unwanted']: indices }); const setPriority = (indices: number[], priority: number) => { const field = priority === PRIORITY.High ? 'priority-high' : priority === PRIORITY.Low ? 'priority-low' : 'priority-normal'; set.mutate({ ids: [torrent.id], [field]: indices }); }; const toggleCollapsed = (key: string) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const renderNode = (node: FileNode, depth: number): React.ReactNode[] => { const isDir = node.index == null; const isCollapsed = collapsed.has(node.key); const nodeStats = node.indices.map((i) => stats[i]).filter(Boolean); const allWanted = nodeStats.length > 0 && nodeStats.every((s) => s!.wanted); const someWanted = nodeStats.some((s) => s!.wanted); const priorities = new Set(nodeStats.map((s) => s!.priority)); const priority = priorities.size === 1 ? [...priorities][0]! : null; const fraction = node.size > 0 ? node.completed / node.size : 0; const row = (
{isDir ? ( ) : ( )} setWanted(node.indices, !allWanted)} className="shrink-0" /> {isDir ? ( ) : ( )} {node.name}
{formatSize(node.size)}
{formatPercent(fraction)}
setPriority(node.indices, PRIORITY.High)}>High setPriority(node.indices, PRIORITY.Normal)}>Normal setPriority(node.indices, PRIORITY.Low)}>Low
); if (isDir && !isCollapsed) { return [row, ...node.children.flatMap((child) => renderNode(child, depth + 1))]; } return [row]; }; return (
{wantedCount} of {torrent.files.length} files ยท {formatSize(wantedBytes)} wanted {[PRIORITY.High, PRIORITY.Normal, PRIORITY.Low].map((p) => ( setPriority( torrent.files!.map((_, i) => i), p, ) } > {PRIORITY_LABELS[p]} ))}
{tree.children.flatMap((child) => renderNode(child, 0))}
!open && setRenaming(null)} torrent={torrent} path={renaming ?? ''} />
); };