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(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 not cached; if (snapshot.status === 'pending') return ( fetching ); if (snapshot.status === 'failed' && !snapshot.directoryCount) return ( failed ); return ( {snapshot.directoryCount.toLocaleString()} folders ); }; /** 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) => ( ); /** The filtered tree, indexed for rendering: children by parent path, plus which rows actually matched. */ type Filtered = { childrenOf: Map; matched: Set }; 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(() => { if (!q || !search.data) return null; const childrenOf = new Map(); 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 (
Shared folders {snapshot && !!snapshot.directoryCount && ( · {snapshot.directoryCount.toLocaleString()} folders · {snapshot.fileCount.toLocaleString()} files ·{' '} {formatSize(snapshot.totalSize)} )}
{hasCache && ( )}
{/* Status banners — a fetch runs server-side, so leaving the panel is safe. */} {pending && (
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.
)} {snapshot?.status === 'failed' && (
Last fetch failed{snapshot.error ? `: ${snapshot.error}` : ''}.{' '} {hasCache && 'Showing the previously cached tree.'}
)} {!hasCache && !pending ? (

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.

) : ( hasCache && ( <>
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 && } {q && ( {(search.data?.total ?? 0).toLocaleString()} matching )}
{/* Never a silent cap: a truncated search still renders a valid tree, just not all of it. */} {search.data?.truncated && q && (
Showing the first {search.data.matched.length.toLocaleString()} of {search.data.total.toLocaleString()}{' '} matches — narrow the filter to see the rest.
)} {q && search.data && search.data.total === 0 ? (

No folders match “{q}”.

) : ( // 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.
{topNodes.map((node) => ( ))}
)} {snapshot?.completedAt && (
cached {formatWhen(snapshot.completedAt)}
)} ) )}
); }; 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 (
{node.subtreeFileCount > 0 && ( enqueue.mutate({ path: node.name })} busy={enqueue.isPending} title={`Queue all ${node.subtreeFileCount.toLocaleString()} files below ${node.label}`} /> )}
{open && ( <> {!scoped && level.isPending && node.childCount > 0 && (

Loading folders…

)} {children.map((child) => ( ))} {/* 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 && ( )} {more && capped && (

Showing {children.length.toLocaleString()} of {total.toLocaleString()} folders.

)} {node.fileCount > 0 && } )}
); }; 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 (

Loading files…

); if (error) return (

Could not load this folder.

); if (!data?.length) return null; return (
{data.map((file) => (
{file.name} {formatSize(file.size)} enqueue.mutate({ path, file: file.name })} busy={enqueue.isPending} title={`Queue ${file.name}`} />
))}
); };