Seventh plugin out, and the first that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts had the router import and its mount commented out, so createSidecarProxy never ran, registerProxiedPrefix was never called, the slskd:server announcement fired into a void, and officer-slskd was not in the ecosystem at all. The permission entry had been reduced to api: [], which is why assertPermissionTotality was satisfied and the server booted clean. The UI rendered perfectly and every request 404'd. So this is a resurrection as well as a move. The mount is now /api/soulseek, derived via mountPrefix from the appName — all 47 frontend call sites repointed from /slskd/. The credential key in service_connections stays 'slskd': it names the daemon, not the route. Those two strings differ on purpose and conflating them reads the wrong row. The connection stays in core service_connections, as transmission does, so 'slskd' remains in a platform union this plugin cannot extend. Recorded in PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and the two halves of the cached browse tree, which slskd has no concept of. Those three tables had been dropped from the drizzle barrel, so bun db:push would have deleted them on any server holding data. 313 palette classes converted to the semantic tone set, including a shared PHASE_TONE the three transfer views previously each copied.
436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
FolderOpen,
|
|
Folder,
|
|
Loader2,
|
|
Search,
|
|
RefreshCw,
|
|
Download,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
TriangleAlert,
|
|
Trash2,
|
|
FileAudio,
|
|
} from 'lucide-react';
|
|
import {
|
|
useSoulseekBrowseSnapshots,
|
|
useSoulseekBrowseLevel,
|
|
useSoulseekBrowseSearch,
|
|
useSoulseekBrowseFiles,
|
|
useSoulseekEnqueue,
|
|
} from './useSoulseekBrowse';
|
|
import { formatSize, formatWhen, type SoulseekBrowseNode, type SoulseekBrowseSnapshot } from './shared';
|
|
|
|
// A peer's shared folders, read from Officer's cache rather than browsed live, and rendered as a TREE.
|
|
//
|
|
// The live slskd call returns the entire tree in one blocking response (59 MB / 18k folders / 284k files
|
|
// for a real peer) and takes minutes, because it round-trips to that peer. So nothing here triggers it
|
|
// inline: you ask the sidecar to fetch, it keeps going without the tab, and this reads back levels.
|
|
//
|
|
// slskd's response is flat — every folder is a full backslash-delimited path — which made the old list
|
|
// unreadable: 26 rows all beginning with the same 31 characters. The sidecar links the paths into a tree
|
|
// at ingest, so a row here shows one segment and one level loads at a time.
|
|
//
|
|
// Filtering never flattens that. The search route returns matches WITH their ancestors, and this renders
|
|
// that skeleton pre-expanded: you see where a hit lives, not just that it exists. Because matching runs
|
|
// against the whole path, a folder matching implies all its descendants match too — so a matched subtree
|
|
// arrives complete.
|
|
|
|
// One level's page size. Fan-out is not gentle: the widest folder measured on a real peer has 1,181
|
|
// children, so a level can ask for more (up to the route's cap) rather than paging with Previous/Next —
|
|
// there's nothing to page through once you're inside one folder.
|
|
const LEVEL_LIMIT = 200;
|
|
const LEVEL_LIMIT_MAX = 2000;
|
|
|
|
/** Debounce the filter box so a keystroke doesn't become a request. */
|
|
function useDebounced<T>(value: T, ms: number): T {
|
|
const [held, setHeld] = useState(value);
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setHeld(value), ms);
|
|
return () => clearTimeout(timer);
|
|
}, [value, ms]);
|
|
return held;
|
|
}
|
|
|
|
/** Cache state for one peer, compact enough for a favourites row. */
|
|
export const BrowseStateChip = ({ snapshot }: { snapshot: SoulseekBrowseSnapshot | null }) => {
|
|
if (!snapshot) return <span className="shrink-0 text-xs text-muted-foreground">not cached</span>;
|
|
if (snapshot.status === 'pending')
|
|
return (
|
|
<span className="flex shrink-0 items-center gap-1 text-xs text-primary">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
fetching
|
|
</span>
|
|
);
|
|
if (snapshot.status === 'failed' && !snapshot.directoryCount)
|
|
return (
|
|
<span className="flex shrink-0 items-center gap-1 text-xs text-destructive" title={snapshot.error ?? undefined}>
|
|
<TriangleAlert className="h-3 w-3" />
|
|
failed
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{snapshot.directoryCount.toLocaleString()} folders
|
|
</span>
|
|
);
|
|
};
|
|
|
|
/** Queue-this button, shown on row hover. Kept out of the row's own button — nesting the two is invalid. */
|
|
type QueueButtonProps = { onQueue: () => void; busy: boolean; title: string };
|
|
|
|
const QueueButton = ({ onQueue, busy, title }: QueueButtonProps) => (
|
|
<button
|
|
type="button"
|
|
onClick={onQueue}
|
|
disabled={busy}
|
|
title={title}
|
|
className="mr-3 flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition hover:bg-muted hover:text-primary focus:opacity-100 disabled:opacity-40 group-hover:opacity-100"
|
|
>
|
|
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
|
</button>
|
|
);
|
|
|
|
/** The filtered tree, indexed for rendering: children by parent path, plus which rows actually matched. */
|
|
type Filtered = { childrenOf: Map<string, SoulseekBrowseNode[]>; matched: Set<string> };
|
|
|
|
export const SharesBrowser = ({ username }: { username: string }) => {
|
|
const { snapshotOf, fetchShares, dropShares } = useSoulseekBrowseSnapshots();
|
|
const [filter, setFilter] = useState('');
|
|
const q = useDebounced(filter.trim(), 250);
|
|
|
|
const snapshot = snapshotOf(username);
|
|
// Folder rows survive a failed or in-flight refresh, so drive the tree off the data existing rather
|
|
// than off the status — a stale tree is still worth browsing while a new one is being fetched.
|
|
const hasCache = !!snapshot && snapshot.directoryCount > 0;
|
|
|
|
const roots = useSoulseekBrowseLevel({ username, parent: null, limit: LEVEL_LIMIT, enabled: hasCache && !q });
|
|
const search = useSoulseekBrowseSearch({ username, q, enabled: hasCache });
|
|
|
|
useEffect(() => {
|
|
setFilter('');
|
|
}, [username]);
|
|
|
|
const filtered = useMemo<Filtered | null>(() => {
|
|
if (!q || !search.data) return null;
|
|
const childrenOf = new Map<string, SoulseekBrowseNode[]>();
|
|
for (const node of search.data.nodes) {
|
|
if (!node.parentPath) continue;
|
|
const siblings = childrenOf.get(node.parentPath);
|
|
if (siblings) siblings.push(node);
|
|
else childrenOf.set(node.parentPath, [node]);
|
|
}
|
|
return { childrenOf, matched: new Set(search.data.matched) };
|
|
}, [q, search.data]);
|
|
|
|
const topNodes = q ? (search.data?.nodes.filter((n) => !n.parentPath) ?? []) : (roots.data?.nodes ?? []);
|
|
const pending = snapshot?.status === 'pending';
|
|
const busy = q ? search.isFetching : roots.isFetching;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card">
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-foreground">Shared folders</span>
|
|
{snapshot && !!snapshot.directoryCount && (
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
· {snapshot.directoryCount.toLocaleString()} folders · {snapshot.fileCount.toLocaleString()} files ·{' '}
|
|
{formatSize(snapshot.totalSize)}
|
|
</span>
|
|
)}
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchShares(username)}
|
|
disabled={pending}
|
|
title={hasCache ? 'Fetch again in the background' : 'Fetch this share tree in the background'}
|
|
className="flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-50"
|
|
>
|
|
{pending ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : hasCache ? (
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Download className="h-3.5 w-3.5" />
|
|
)}
|
|
{pending ? 'Fetching…' : hasCache ? 'Refresh' : 'Fetch shares'}
|
|
</button>
|
|
{hasCache && (
|
|
<button
|
|
type="button"
|
|
onClick={() => dropShares(username)}
|
|
title="Drop this cached share tree"
|
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status banners — a fetch runs server-side, so leaving the panel is safe. */}
|
|
{pending && (
|
|
<div className="flex items-center gap-2 border-b border-border bg-primary/5 px-4 py-2 text-xs text-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
|
|
Fetching this peer's shares in the background — it can take a few minutes for a large share, and it keeps
|
|
going if you navigate away.
|
|
</div>
|
|
)}
|
|
{snapshot?.status === 'failed' && (
|
|
<div className="flex items-start gap-2 border-b border-border bg-destructive/10 px-4 py-2 text-xs text-destructive">
|
|
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span>
|
|
Last fetch failed{snapshot.error ? `: ${snapshot.error}` : ''}.{' '}
|
|
{hasCache && 'Showing the previously cached tree.'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{!hasCache && !pending ? (
|
|
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
|
|
<Folder className="h-8 w-8 text-muted-foreground" />
|
|
<p className="max-w-sm text-sm text-muted-foreground">
|
|
No cached shares for this peer yet. Fetching runs on the server and survives you closing the tab — come back
|
|
and it'll be here.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
hasCache && (
|
|
<>
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
|
|
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<input
|
|
value={filter}
|
|
onChange={(ev) => setFilter(ev.target.value)}
|
|
placeholder="Filter folders…"
|
|
className="h-7 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
{busy && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />}
|
|
{q && (
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{(search.data?.total ?? 0).toLocaleString()} matching
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Never a silent cap: a truncated search still renders a valid tree, just not all of it. */}
|
|
{search.data?.truncated && q && (
|
|
<div className="border-b border-border bg-warning/10 px-4 py-1.5 text-xs text-warning">
|
|
Showing the first {search.data.matched.length.toLocaleString()} of {search.data.total.toLocaleString()}{' '}
|
|
matches — narrow the filter to see the rest.
|
|
</div>
|
|
)}
|
|
|
|
{q && search.data && search.data.total === 0 ? (
|
|
<p className="px-4 py-6 text-center text-sm text-muted-foreground">No folders match “{q}”.</p>
|
|
) : (
|
|
// Keyed by the term so switching between browsing and a filtered view remounts the tree:
|
|
// every row's open state is local, and reusing instances across modes would strand it.
|
|
<div key={q} className="max-h-[32rem] overflow-y-auto py-1">
|
|
{topNodes.map((node) => (
|
|
<TreeNode
|
|
key={node.id}
|
|
username={username}
|
|
node={node}
|
|
filtered={filtered}
|
|
indent={0}
|
|
initialOpen={!!filtered || topNodes.length === 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{snapshot?.completedAt && (
|
|
<div className="border-t border-border/50 px-4 py-1.5 text-right text-xs text-muted-foreground">
|
|
cached {formatWhen(snapshot.completedAt)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type TreeNodeProps = {
|
|
username: string;
|
|
node: SoulseekBrowseNode;
|
|
/** Non-null in filter mode: children come from this set, not from a request, and rows start open. */
|
|
filtered: Filtered | null;
|
|
indent: number;
|
|
initialOpen: boolean;
|
|
};
|
|
|
|
const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodeProps) => {
|
|
const [limit, setLimit] = useState(LEVEL_LIMIT);
|
|
const [revealed, setRevealed] = useState(false);
|
|
const enqueue = useSoulseekEnqueue(username);
|
|
|
|
// Filtering stops at a revealed row: from here down it's ordinary lazy browsing, so opening a matched
|
|
// folder shows what's really inside it rather than the filtered skeleton — which is why you searched.
|
|
const scoped = revealed ? null : filtered;
|
|
const scopedChildren = scoped?.childrenOf.get(node.name) ?? [];
|
|
|
|
// A filtered row starts open only when the filter matched something below it. One that matched on its own
|
|
// name has nothing of its own to show, so it stays shut and behaves like any other collapsed folder.
|
|
const [open, setOpen] = useState(() => (filtered ? scopedChildren.length > 0 : initialOpen));
|
|
|
|
const expandable = node.childCount > 0 || node.fileCount > 0;
|
|
const level = useSoulseekBrowseLevel({
|
|
username,
|
|
parent: node.name,
|
|
limit,
|
|
enabled: !scoped && open && node.childCount > 0,
|
|
});
|
|
|
|
const children = scoped ? scopedChildren : (level.data?.nodes ?? []);
|
|
// In the filtered skeleton no level was fetched, so the row's own count is what says how many exist.
|
|
const total = scoped ? node.childCount : (level.data?.total ?? node.childCount);
|
|
const capped = !scoped && limit >= LEVEL_LIMIT_MAX;
|
|
// Not while a level is in flight: `total` falls back to the row's own count then, so every loading row
|
|
// would flash a "show all" button for children it's already fetching.
|
|
const more = open && total > children.length && (!!scoped || !level.isPending);
|
|
const isMatch = filtered?.matched.has(node.name) ?? false;
|
|
|
|
const toggle = () => {
|
|
// Opening a matched row with nothing matched below it leaves the filter behind, rather than opening
|
|
// into an empty folder.
|
|
if (!open && scoped && !scopedChildren.length) setRevealed(true);
|
|
setOpen((v) => !v);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="group flex items-center transition-colors hover:bg-muted/30">
|
|
<button
|
|
type="button"
|
|
onClick={() => expandable && toggle()}
|
|
style={{ paddingLeft: 12 + indent * 14 }}
|
|
className={`flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left text-sm ${
|
|
expandable ? '' : 'cursor-default'
|
|
}`}
|
|
>
|
|
{expandable ? (
|
|
open ? (
|
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
)
|
|
) : (
|
|
<span className="h-3.5 w-3.5 shrink-0" />
|
|
)}
|
|
{open ? (
|
|
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span
|
|
className={`min-w-0 flex-1 truncate ${isMatch ? 'font-medium text-primary' : 'text-foreground'}`}
|
|
title={node.name}
|
|
>
|
|
{node.label}
|
|
</span>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `}
|
|
{node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)}
|
|
</span>
|
|
</button>
|
|
{node.subtreeFileCount > 0 && (
|
|
<QueueButton
|
|
onQueue={() => enqueue.mutate({ path: node.name })}
|
|
busy={enqueue.isPending}
|
|
title={`Queue all ${node.subtreeFileCount.toLocaleString()} files below ${node.label}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{open && (
|
|
<>
|
|
{!scoped && level.isPending && node.childCount > 0 && (
|
|
<p
|
|
style={{ paddingLeft: 12 + (indent + 1) * 14 }}
|
|
className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground"
|
|
>
|
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading folders…
|
|
</p>
|
|
)}
|
|
{children.map((child) => (
|
|
<TreeNode
|
|
key={child.id}
|
|
username={username}
|
|
node={child}
|
|
filtered={scoped}
|
|
indent={indent + 1}
|
|
initialOpen={!!scoped}
|
|
/>
|
|
))}
|
|
{/* One button for both modes: in the filtered skeleton it drops this row out of the filter, in
|
|
browse mode it widens the level. Wide levels load in one go — there's nothing to page through. */}
|
|
{more && !capped && (
|
|
<button
|
|
type="button"
|
|
onClick={() => (scoped ? setRevealed(true) : setLimit(Math.min(total, LEVEL_LIMIT_MAX)))}
|
|
style={{ paddingLeft: 12 + (indent + 1) * 14 }}
|
|
className="py-1 text-xs text-primary/80 transition hover:text-primary"
|
|
>
|
|
Show all {total.toLocaleString()} folders
|
|
</button>
|
|
)}
|
|
{more && capped && (
|
|
<p style={{ paddingLeft: 12 + (indent + 1) * 14 }} className="py-1 text-xs text-muted-foreground">
|
|
Showing {children.length.toLocaleString()} of {total.toLocaleString()} folders.
|
|
</p>
|
|
)}
|
|
{node.fileCount > 0 && <DirFiles username={username} dirId={node.id} path={node.name} indent={indent + 1} />}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type DirFilesProps = { username: string; dirId: number; path: string; indent: number };
|
|
|
|
const DirFiles = ({ username, dirId, path, indent }: DirFilesProps) => {
|
|
const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId);
|
|
const enqueue = useSoulseekEnqueue(username);
|
|
const pad = { paddingLeft: 12 + indent * 14 };
|
|
|
|
if (isPending)
|
|
return (
|
|
<p style={pad} className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading files…
|
|
</p>
|
|
);
|
|
if (error)
|
|
return (
|
|
<p style={pad} className="py-1 text-xs text-destructive">
|
|
Could not load this folder.
|
|
</p>
|
|
);
|
|
if (!data?.length) return null;
|
|
|
|
return (
|
|
<div className="max-h-72 overflow-y-auto">
|
|
{data.map((file) => (
|
|
<div
|
|
key={file.name}
|
|
style={pad}
|
|
className="group flex items-center gap-1.5 py-1 text-xs transition-colors hover:bg-muted/30"
|
|
>
|
|
<span className="h-3.5 w-3.5 shrink-0" />
|
|
<FileAudio className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate text-muted-foreground" title={file.name}>
|
|
{file.name}
|
|
</span>
|
|
<span className="shrink-0 tabular-nums text-muted-foreground">{formatSize(file.size)}</span>
|
|
<QueueButton
|
|
onQueue={() => enqueue.mutate({ path, file: file.name })}
|
|
busy={enqueue.isPending}
|
|
title={`Queue ${file.name}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|