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>
246 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
};
|