Files
transmission/web/detail/FilesTab.tsx
T
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and
29 web files; no db/ directory, because there are no tables.

── The one thing that is not self-contained ──

The connection lives in `service_connections`, a CORE table, reached through
getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin
declares no schema.

Deliberate: that table is the app store's shared credential store, keyed on
(user_id, service), already encrypted at rest and already read by
app-store/effects.ts. A private copy of the same three columns would be a
second place the same fact could live, and the runbook is explicit that a move
plus a data-format change gives any failure two causes.

The cost, stated rather than hidden: this plugin depends on a core facility it
does not own, and would break if that table changed shape. Photos, by contrast,
owns photos_config outright.

── 38 palette classes paid down, mapped by meaning ──

  blue     downloading / download speed  -> info
  cyan     checking (verifying)          -> busy
  emerald  seeding / complete            -> success
  amber    queued / stalled / warnings   -> warning

Collapsing blue and cyan into one tone would have lost a real distinction in
the status dot, so `checking` went to `busy` — in-progress work, which is what
that tone is for.

── A near-miss worth recording ──

The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/
— ten components. tsgo caught it as ten "cannot find module" errors. Worth
stating because it is quiet in the other direction: had those been imported
lazily, the typecheck would have passed and the panels would have broken at
runtime. Check the directory tree, not the file glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

246 lines
9.2 KiB
TypeScript

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<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(null);
const tree = useMemo(() => buildTree(torrent), [torrent.files, torrent.id]);
const stats = torrent.fileStats ?? [];
if (!torrent.files?.length) {
return (
<p className="p-6 text-center text-sm text-muted-foreground">
{torrent.metadataPercentComplete < 1
? 'Still fetching metadata — the file list appears once the magnet resolves.'
: 'This torrent has no files.'}
</p>
);
}
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 = (
<div
key={node.key}
className="grid grid-cols-[minmax(0,1fr)_90px_120px_90px_28px] items-center gap-2 border-b border-border/40 px-3 py-1.5 text-xs hover:bg-muted/40"
>
<div className="flex min-w-0 items-center gap-1.5" style={{ paddingLeft: depth * 14 }}>
{isDir ? (
<button type="button" onClick={() => toggleCollapsed(node.key)} className="shrink-0">
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isCollapsed ? '' : 'rotate-90'}`} />
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
<Checkbox
checked={allWanted ? true : someWanted ? 'indeterminate' : false}
onCheckedChange={() => setWanted(node.indices, !allWanted)}
className="shrink-0"
/>
{isDir ? (
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className={`truncate ${someWanted ? '' : 'text-muted-foreground line-through'}`} title={node.key}>
{node.name}
</span>
</div>
<span className="text-right tabular-nums text-muted-foreground">{formatSize(node.size)}</span>
<div className="flex items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-info" style={{ width: `${fraction * 100}%` }} />
</div>
<span className="w-10 shrink-0 text-right tabular-nums text-muted-foreground">{formatPercent(fraction)}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="text-left text-muted-foreground hover:text-foreground">
{priority == null ? 'mixed' : PRIORITY_LABELS[priority]}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.High)}>High</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Normal)}>Normal</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Low)}>Low</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => setRenaming(node.key)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Rename ${node.name}`}
>
<Pencil className="h-3 w-3" />
</button>
</div>
);
if (isDir && !isCollapsed) {
return [row, ...node.children.flatMap((child) => renderNode(child, depth + 1))];
}
return [row];
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b border-border px-3 py-2 text-xs text-muted-foreground">
<span>
{wantedCount} of {torrent.files.length} files · {formatSize(wantedBytes)} wanted
</span>
<span className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() =>
setWanted(
torrent.files!.map((_, i) => i),
true,
)
}
>
Select all
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
Set all priorities
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[PRIORITY.High, PRIORITY.Normal, PRIORITY.Low].map((p) => (
<DropdownMenuItem
key={p}
onSelect={() =>
setPriority(
torrent.files!.map((_, i) => i),
p,
)
}
>
{PRIORITY_LABELS[p]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="min-h-0 flex-1 overflow-auto">{tree.children.flatMap((child) => renderNode(child, 0))}</div>
<RenameDialog
open={renaming != null}
onOpenChange={(open) => !open && setRenaming(null)}
torrent={torrent}
path={renaming ?? ''}
/>
</div>
);
};