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

251 lines
11 KiB
TypeScript

import type { MouseEvent } from 'react';
import type { Torrent, TorrentAction } from './shared';
import type { ColumnKey } from './columns';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Loader2, PackageOpen } from 'lucide-react';
import { useLocalStorageState } from 'hooks';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { COLUMN_BY_KEY, DEFAULT_VISIBLE_COLUMNS, PINNED_COLUMN } from './columns';
import { buildFacets, selectTorrents, useTorrentFilters } from './useTorrentFilters';
import { useSession, useTorrentMutations, useTorrents } from './useTransmissionData';
import { TorrentToolbar } from './TorrentToolbar';
import { TorrentTable } from './TorrentTable';
import { TorrentDetail } from './TorrentDetail';
import { AddTorrentDialog } from './dialogs/AddTorrentDialog';
import { RemoveTorrentDialog } from './dialogs/RemoveTorrentDialog';
import { MoveTorrentDialog } from './dialogs/MoveTorrentDialog';
import { LabelsDialog } from './dialogs/LabelsDialog';
import { TorrentOptionsDialog } from './dialogs/TorrentOptionsDialog';
// The torrents section: toolbar, list, and the detail pane for ?selected=.
//
// Two kinds of selection live here and they are deliberately different things:
// • the multi-selection (local state) is what the toolbar and the context menu act on. It is ephemeral —
// a checkbox state, not an address, and putting forty ids in the query string would be absurd.
// • ?selected= is the one torrent whose detail pane is open. That IS an address: it survives a reload
// and can be sent to yourself.
const COLUMNS_STORAGE_KEY = 'transmission:columns';
/** How much of the pane the detail takes when open. */
const DETAIL_HEIGHT = '45%';
export const TorrentsView = () => {
const { torrents, isLoading, error } = useTorrents();
const { session, save } = useSession();
const { act } = useTorrentMutations();
const { filters, setSearch, setSort, selected, setSelected } = useTorrentFilters();
const [visibleKeys, setVisibleKeys] = useLocalStorageState<ColumnKey[]>(COLUMNS_STORAGE_KEY, DEFAULT_VISIBLE_COLUMNS);
const [selection, setSelection] = useState<Set<number>>(() => new Set());
const anchorRef = useRef<number | null>(null);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState(false);
const [moving, setMoving] = useState(false);
const [labelling, setLabelling] = useState(false);
const [optioning, setOptioning] = useState(false);
const rows = useMemo(() => selectTorrents(torrents, filters), [torrents, filters]);
const facets = useMemo(() => buildFacets(torrents), [torrents]);
const columns = useMemo(
() =>
// Ordered by the catalogue, not by the stored array — a saved order from an older column set would
// otherwise silently drop the columns added since.
[...visibleKeys].map((key) => COLUMN_BY_KEY.get(key)).filter((c): c is NonNullable<typeof c> => !!c),
[visibleKeys],
);
const byId = useMemo(() => new Map(torrents.map((t) => [t.id, t])), [torrents]);
const selectedTorrents = useMemo(
() => [...selection].map((id) => byId.get(id)).filter((t): t is Torrent => !!t),
[selection, byId],
);
const onRowClick = useCallback(
(torrent: Torrent, ev: MouseEvent) => {
const index = rows.findIndex((r) => r.id === torrent.id);
if (ev.shiftKey && anchorRef.current != null) {
const anchorIndex = rows.findIndex((r) => r.id === anchorRef.current);
if (anchorIndex >= 0) {
const [from, to] = anchorIndex < index ? [anchorIndex, index] : [index, anchorIndex];
setSelection(new Set(rows.slice(from, to + 1).map((r) => r.id)));
return;
}
}
if (ev.ctrlKey || ev.metaKey) {
setSelection((prev) => {
const next = new Set(prev);
if (next.has(torrent.id)) next.delete(torrent.id);
else next.add(torrent.id);
return next;
});
anchorRef.current = torrent.id;
return;
}
setSelection(new Set([torrent.id]));
anchorRef.current = torrent.id;
// A plain click both selects and opens — the detail pane is the point of clicking a row.
setSelected(torrent.id);
},
[rows, setSelected],
);
const onRowContextMenu = useCallback((torrent: Torrent) => {
// Right-clicking outside the selection replaces it. Right-clicking inside keeps it, so a menu action on
// a 20-row selection doesn't silently collapse to the one row under the cursor.
setSelection((prev) => (prev.has(torrent.id) ? prev : new Set([torrent.id])));
anchorRef.current = torrent.id;
}, []);
const runAction = useCallback(
(action: TorrentAction) => {
if (selection.size === 0) return;
act.mutate({ ids: [...selection], action });
},
[act, selection],
);
const toggleColumn = useCallback(
(key: ColumnKey) => {
if (key === PINNED_COLUMN) return;
setVisibleKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
},
[setVisibleKeys],
);
const altSpeed = session?.['alt-speed-enabled'] === true;
if (isLoading && torrents.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading torrents…
</div>
);
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
<div className="text-sm font-medium">Cannot reach Transmission</div>
<p className="max-w-md text-xs text-muted-foreground">
The officer-transmission sidecar answered with an error. Check that the daemon is running, and that the URL
under Connection still points at it.
</p>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<TorrentToolbar
selected={selectedTorrents}
search={filters.q}
onSearch={setSearch}
onAct={runAction}
onAdd={() => setAdding(true)}
onRemove={() => selection.size > 0 && setRemoving(true)}
onMove={() => selection.size > 0 && setMoving(true)}
onLabels={() => selection.size > 0 && setLabelling(true)}
onOptions={() => selection.size > 0 && setOptioning(true)}
altSpeed={altSpeed}
onToggleAltSpeed={() => save.mutate({ 'alt-speed-enabled': !altSpeed })}
total={torrents.length}
shown={rows.length}
/>
<div className="min-h-0 flex-1">
{rows.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<PackageOpen className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold">{filters.isFiltered ? 'Nothing matches' : 'No torrents'}</div>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{filters.isFiltered
? 'Clear the filters in the sidebar to see everything again.'
: 'Add a magnet link or a .torrent file to get started.'}
</p>
</div>
</div>
) : (
<TorrentTable
torrents={rows}
columns={columns}
selection={selection}
focused={selected}
onRowClick={onRowClick}
sort={filters.sort}
desc={filters.desc}
onSort={(key) => setSort(key, filters.sort, filters.desc)}
visibleKeys={visibleKeys}
onToggleColumn={toggleColumn}
onResetColumns={() => setVisibleKeys(DEFAULT_VISIBLE_COLUMNS)}
onRowContextMenu={onRowContextMenu}
rowMenu={() => (
<>
<ContextMenuItem onSelect={() => runAction('start')}>Start</ContextMenuItem>
<ContextMenuItem onSelect={() => runAction('start-now')}>Start now</ContextMenuItem>
<ContextMenuItem onSelect={() => runAction('stop')}>Pause</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => runAction('verify')}>Verify local data</ContextMenuItem>
<ContextMenuItem onSelect={() => runAction('reannounce')}>Ask for more peers</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => runAction('queue-top')}>Move to top of queue</ContextMenuItem>
<ContextMenuItem onSelect={() => runAction('queue-up')}>Move up</ContextMenuItem>
<ContextMenuItem onSelect={() => runAction('queue-down')}>Move down</ContextMenuItem>
<ContextMenuItem onSelect={() => runAction('queue-bottom')}>Move to bottom</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => setMoving(true)}>Set location…</ContextMenuItem>
<ContextMenuItem onSelect={() => setLabelling(true)}>Labels…</ContextMenuItem>
<ContextMenuItem onSelect={() => setOptioning(true)}>Torrent options…</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive" onSelect={() => setRemoving(true)}>
Remove…
</ContextMenuItem>
</>
)}
/>
)}
</div>
{selected != null && (
<div style={{ height: DETAIL_HEIGHT }} className="min-h-0 shrink-0">
<TorrentDetail id={selected} onClose={() => setSelected(null)} />
</div>
)}
<AddTorrentDialog open={adding} onOpenChange={setAdding} />
<RemoveTorrentDialog
open={removing}
onOpenChange={setRemoving}
targets={selectedTorrents}
onRemoved={() => {
setSelection(new Set());
// The detail pane would keep polling a torrent that no longer exists and settle on "that torrent
// is gone" — closing it is both correct and less alarming.
if (selected != null && selection.has(selected)) setSelected(null);
}}
/>
<MoveTorrentDialog
open={moving}
onOpenChange={setMoving}
targets={selectedTorrents}
knownDirs={facets.dirs.map((d) => d.value)}
/>
<LabelsDialog
open={labelling}
onOpenChange={setLabelling}
targets={selectedTorrents}
known={facets.labels.map((l) => l.value)}
/>
<TorrentOptionsDialog open={optioning} onOpenChange={setOptioning} targets={selectedTorrents} />
</div>
);
};