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>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user