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

311 lines
8.8 KiB
TypeScript

import type { ReactNode } from 'react';
import type { Torrent } from './shared';
import type { SortKey } from './useTorrentFilters';
import { Lock } from 'lucide-react';
import {
formatDate,
formatDuration,
formatEta,
formatPercent,
formatRatio,
formatSize,
formatSpeed,
PRIORITY_LABELS,
statusLabel,
statusTone,
TONE_CLASSES,
} from './format';
// Column catalogue for the torrent table. Mirrors the reference client's set, minus the columns that only
// exist there because its canvas renderer needed them split (uploadedDownloaded, the separate seeds cell).
//
// Widths are fixed px rather than fractions: the table scrolls horizontally and is virtualised vertically,
// so a row has to be laid out without measuring, and a percentage-width grid inside a horizontal scroller
// collapses to the viewport instead of the content.
export type ColumnKey =
| 'name'
| 'status'
| 'percentDone'
| 'totalSize'
| 'sizeWhenDone'
| 'leftUntilDone'
| 'haveValid'
| 'downloadedEver'
| 'uploadedEver'
| 'rateDownload'
| 'rateUpload'
| 'eta'
| 'uploadRatio'
| 'peersSendingToUs'
| 'peersGettingFromUs'
| 'addedDate'
| 'doneDate'
| 'activityDate'
| 'secondsSeeding'
| 'downloadDir'
| 'officerTrackerHost'
| 'officerTrackerStatus'
| 'bandwidthPriority'
| 'queuePosition'
| 'labels'
| 'isPrivate'
| 'group'
| 'file-count'
| 'pieceCount'
| 'id';
export type ColumnDef = {
key: ColumnKey;
label: string;
width: number;
align?: 'right' | 'center';
/** Absent when the column has no meaningful order (nothing here, currently — kept for honesty). */
sortKey?: SortKey;
render: (t: Torrent) => ReactNode;
};
const num = (v: number | undefined) => (v == null ? '—' : v.toLocaleString());
/** A count of zero is noise in a dense table — the absence of peers is already visible as a blank. */
const positive = (v: number | undefined) => (v ? v.toLocaleString() : '');
const ProgressCell = ({ torrent }: { torrent: Torrent }) => {
const tone = TONE_CLASSES[statusTone(torrent)];
// Verifying reports its own progress in recheckProgress; percentDone stays where it was, so a recheck of
// a complete torrent would otherwise render a full bar while the daemon is 3% into the hash check.
const isChecking = torrent.recheckProgress != null && torrent.recheckProgress > 0 && torrent.percentDone < 1;
const fraction = isChecking ? torrent.recheckProgress! : torrent.percentDone;
return (
<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 ${tone.dot}`} style={{ width: `${Math.min(100, fraction * 100)}%` }} />
</div>
<span className="w-11 shrink-0 text-right tabular-nums text-muted-foreground">{formatPercent(fraction)}</span>
</div>
);
};
export const ALL_COLUMNS: ColumnDef[] = [
{
key: 'name',
label: 'Name',
width: 340,
sortKey: 'name',
render: (t) => (
<span className="flex min-w-0 items-center gap-1.5">
{t.isPrivate && <Lock className="h-3 w-3 shrink-0 text-warning" aria-label="Private torrent" />}
<span className="truncate" title={t.name}>
{t.name}
</span>
</span>
),
},
{
key: 'status',
label: 'Status',
width: 130,
sortKey: 'status',
render: (t) => {
const tone = TONE_CLASSES[statusTone(t)];
return (
<span className={`inline-flex items-center gap-1.5 ${tone.text}`} title={t.errorString || undefined}>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${tone.dot}`} />
<span className="truncate">{statusLabel(t)}</span>
</span>
);
},
},
{
key: 'percentDone',
label: 'Progress',
width: 150,
sortKey: 'percentDone',
render: (t) => <ProgressCell torrent={t} />,
},
{
key: 'totalSize',
label: 'Size',
width: 90,
align: 'right',
sortKey: 'totalSize',
render: (t) => formatSize(t.totalSize),
},
{
key: 'sizeWhenDone',
label: 'Size when done',
width: 120,
align: 'right',
render: (t) => formatSize(t.sizeWhenDone),
},
{ key: 'leftUntilDone', label: 'Remaining', width: 100, align: 'right', render: (t) => formatSize(t.leftUntilDone) },
{ key: 'haveValid', label: 'Have', width: 90, align: 'right', render: (t) => formatSize(t.haveValid) },
{
key: 'downloadedEver',
label: 'Downloaded',
width: 105,
align: 'right',
sortKey: 'downloadedEver',
render: (t) => formatSize(t.downloadedEver),
},
{
key: 'uploadedEver',
label: 'Uploaded',
width: 100,
align: 'right',
sortKey: 'uploadedEver',
render: (t) => formatSize(t.uploadedEver),
},
{
key: 'rateDownload',
label: 'Down',
width: 95,
align: 'right',
sortKey: 'rateDownload',
render: (t) => <span className="text-info">{formatSpeed(t.rateDownload)}</span>,
},
{
key: 'rateUpload',
label: 'Up',
width: 95,
align: 'right',
sortKey: 'rateUpload',
render: (t) => <span className="text-success">{formatSpeed(t.rateUpload)}</span>,
},
{
key: 'eta',
label: 'ETA',
width: 85,
align: 'right',
sortKey: 'eta',
// ETA is meaningless once a torrent is done — Transmission keeps returning the sentinel and the column
// would read "∞" down the whole seeding half of the list.
render: (t) => (t.percentDone >= 1 ? '' : formatEta(t.eta)),
},
{
key: 'uploadRatio',
label: 'Ratio',
width: 75,
align: 'right',
sortKey: 'uploadRatio',
render: (t) => formatRatio(t.uploadRatio),
},
{
key: 'peersSendingToUs',
label: 'Seeds',
width: 70,
align: 'right',
sortKey: 'peersSendingToUs',
render: (t) => positive(t.peersSendingToUs),
},
{
key: 'peersGettingFromUs',
label: 'Peers',
width: 70,
align: 'right',
sortKey: 'peersGettingFromUs',
render: (t) => positive(t.peersGettingFromUs),
},
{ key: 'addedDate', label: 'Added', width: 145, sortKey: 'addedDate', render: (t) => formatDate(t.addedDate) },
{ key: 'doneDate', label: 'Completed', width: 145, sortKey: 'doneDate', render: (t) => formatDate(t.doneDate) },
{
key: 'activityDate',
label: 'Last active',
width: 145,
sortKey: 'activityDate',
render: (t) => formatDate(t.activityDate),
},
{
key: 'secondsSeeding',
label: 'Seeding time',
width: 110,
align: 'right',
sortKey: 'secondsSeeding',
render: (t) => formatDuration(t.secondsSeeding),
},
{
key: 'downloadDir',
label: 'Location',
width: 220,
sortKey: 'downloadDir',
render: (t) => (
<span className="truncate" title={t.downloadDir}>
{t.downloadDir}
</span>
),
},
{
key: 'officerTrackerHost',
label: 'Tracker',
width: 150,
sortKey: 'officerTrackerHost',
render: (t) => t.officerTrackerHost || '—',
},
{
key: 'officerTrackerStatus',
label: 'Tracker status',
width: 190,
render: (t) => (
<span className={t.officerTrackerErrors > 0 ? 'text-warning' : undefined} title={t.officerTrackerStatus}>
<span className="truncate">{t.officerTrackerStatus || '—'}</span>
</span>
),
},
{
key: 'bandwidthPriority',
label: 'Priority',
width: 85,
render: (t) => PRIORITY_LABELS[t.bandwidthPriority] ?? '—',
},
{
key: 'queuePosition',
label: 'Queue',
width: 70,
align: 'right',
sortKey: 'queuePosition',
render: (t) => num(t.queuePosition),
},
{
key: 'labels',
label: 'Labels',
width: 160,
render: (t) =>
t.labels.length ? (
<span className="flex gap-1 overflow-hidden">
{t.labels.map((l) => (
<span key={l} className="shrink-0 rounded bg-muted px-1.5 py-px text-[10px] leading-4">
{l}
</span>
))}
</span>
) : (
''
),
},
{ key: 'isPrivate', label: 'Private', width: 70, align: 'center', render: (t) => (t.isPrivate ? 'Yes' : 'No') },
{ key: 'group', label: 'Group', width: 100, render: (t) => t.group || '' },
{ key: 'file-count', label: 'Files', width: 70, align: 'right', render: (t) => num(t['file-count']) },
{ key: 'pieceCount', label: 'Pieces', width: 80, align: 'right', render: (t) => num(t.pieceCount) },
{ key: 'id', label: 'ID', width: 60, align: 'right', sortKey: 'id', render: (t) => String(t.id) },
];
export const COLUMN_BY_KEY = new Map(ALL_COLUMNS.map((c) => [c.key, c]));
/** What a fresh install shows. Everything else is one right-click on the header away. */
export const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = [
'name',
'status',
'percentDone',
'totalSize',
'rateDownload',
'rateUpload',
'eta',
'uploadRatio',
'peersSendingToUs',
'peersGettingFromUs',
'addedDate',
];
/** The name column is the only thing identifying a row, so it is not hideable. */
export const PINNED_COLUMN: ColumnKey = 'name';