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