Files
soulseek/web/SharesBrowser.tsx
T
officer c11f45c033 soulseek, extracted from the platform as a plugin
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.
2026-08-19 02:55:02 +00:00

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>
);
};