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>
251 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|