Files
transmission/web/TorrentTable.tsx
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

168 lines
6.7 KiB
TypeScript

import type { MouseEvent } from 'react';
import type { Torrent } from './shared';
import type { ColumnDef, ColumnKey } from './columns';
import type { SortKey } from './useTorrentFilters';
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ArrowDown, ArrowUp, Check } from 'lucide-react';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { ALL_COLUMNS, PINNED_COLUMN } from './columns';
// The torrent list. Virtualised because the row count is unbounded and the whole thing re-renders on a
// 5-second poll — 43 rows is fine, 4000 is not, and a torrent client accumulates.
//
// Layout is a fixed-width grid inside a single horizontal scroller shared by the header and the body, so
// the two cannot drift apart the way two independently-scrolled elements do.
const ROW_HEIGHT = 30;
type TorrentTableProps = {
torrents: Torrent[];
columns: ColumnDef[];
/** Multi-selection, for the bulk actions in the toolbar and the row context menu. */
selection: Set<number>;
/** The one torrent whose detail pane is open (?selected=) — a different thing from being in `selection`. */
focused: number | null;
onRowClick: (torrent: Torrent, ev: MouseEvent) => void;
sort: SortKey;
desc: boolean;
onSort: (key: SortKey) => void;
visibleKeys: ColumnKey[];
onToggleColumn: (key: ColumnKey) => void;
onResetColumns: () => void;
/** Right-clicking a row selects it if it isn't already, then opens the actions menu. */
onRowContextMenu: (torrent: Torrent) => void;
rowMenu: (torrent: Torrent) => React.ReactNode;
};
export const TorrentTable = ({
torrents,
columns,
selection,
focused,
onRowClick,
sort,
desc,
onSort,
visibleKeys,
onToggleColumn,
onResetColumns,
onRowContextMenu,
rowMenu,
}: TorrentTableProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: torrents.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 12,
});
const gridTemplate = columns.map((c) => `${c.width}px`).join(' ');
const totalWidth = columns.reduce((sum, c) => sum + c.width, 0);
return (
<div ref={scrollRef} className="h-full overflow-auto">
{/* min-w on an inline-block wrapper: the header must be as wide as the widest row so it keeps
scrolling with the body rather than clipping at the viewport edge. */}
<div style={{ minWidth: totalWidth }}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="sticky top-0 z-10 grid border-b border-border bg-background/95 backdrop-blur"
style={{ gridTemplateColumns: gridTemplate }}
>
{columns.map((col) => (
<button
key={col.key}
type="button"
disabled={!col.sortKey}
onClick={() => col.sortKey && onSort(col.sortKey)}
className={`flex items-center gap-1 overflow-hidden px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground ${
col.sortKey ? 'hover:text-foreground' : 'cursor-default'
} ${col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : ''}`}
>
<span className="truncate">{col.label}</span>
{col.sortKey === sort &&
(desc ? <ArrowDown className="h-3 w-3 shrink-0" /> : <ArrowUp className="h-3 w-3 shrink-0" />)}
</button>
))}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="max-h-[70vh] w-56 overflow-y-auto">
{ALL_COLUMNS.map((col) => {
const shown = visibleKeys.includes(col.key);
const pinned = col.key === PINNED_COLUMN;
return (
<ContextMenuItem
key={col.key}
disabled={pinned}
onSelect={(ev) => {
// Keep the menu open — picking columns is a several-clicks job and reopening it each
// time, at the same mouse position, is a small torture.
ev.preventDefault();
onToggleColumn(col.key);
}}
>
<Check className={`mr-2 h-3.5 w-3.5 ${shown ? '' : 'opacity-0'}`} />
{col.label}
</ContextMenuItem>
);
})}
<ContextMenuItem onSelect={onResetColumns}>Reset to defaults</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => {
const torrent = torrents[row.index];
if (!torrent) return null;
const isSelected = selection.has(torrent.id);
const isFocused = focused === torrent.id;
return (
<ContextMenu key={torrent.id}>
<ContextMenuTrigger asChild>
<div
onClick={(ev) => onRowClick(torrent, ev)}
onContextMenu={() => onRowContextMenu(torrent)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: row.size,
transform: `translateY(${row.start}px)`,
gridTemplateColumns: gridTemplate,
}}
className={`grid cursor-default items-center border-b border-border/40 text-xs ${
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
} ${isFocused ? 'ring-1 ring-inset ring-primary/40' : ''}`}
>
{columns.map((col) => (
<div
key={col.key}
className={`min-w-0 overflow-hidden whitespace-nowrap px-2 ${
col.align === 'right'
? 'text-right tabular-nums'
: col.align === 'center'
? 'text-center'
: ''
}`}
>
{col.render(torrent)}
</div>
))}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-56">{rowMenu(torrent)}</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</div>
</div>
);
};