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>
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user