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>
311 lines
8.8 KiB
TypeScript
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';
|