soulseek: render cached shares as a tree, not a path list

slskd's browse response is flat: every folder is a full backslash-delimited
path. Rendered as-is, a filter for "pogues" gave 26 rows that all began with
the same 31 characters, and the real hierarchy — which is the only way to tell
an artist folder from an album folder — was invisible.

The shape is now derived once, at ingest, in the sidecar: buildTree() links
each path to its parent, synthesizes any ancestor slskd omitted (measured:
exactly one missing across ~30k folders on two real peers, but a single gap
would strand a whole subtree), and rolls subtree file counts and sizes up
bottom-up. A parent's own files are usually just cover art, so the number
worth showing on a collapsed row is the subtree's.

Storing the shape rather than recomputing it is what lets the UI open one
level at a time. Levels are still paged, because fan-out is brutal — the
widest folder measured has 1,181 children.

Filtering keeps the tree instead of falling back to a list: the search route
returns matches plus every ancestor, and the UI renders that skeleton
pre-expanded, so you see where a hit lives. Matching runs against the whole
path, so a matched folder implies its descendants match too and a matched
subtree arrives complete. The match cap is reported in the payload and shown
in the UI rather than passed off as the whole answer.

The two existing snapshots were backfilled by scripts/rebuild-soulseek-tree.ts,
which runs the same buildTree + finishSoulseekBrowse the ingest path runs — no
second implementation to drift, and no peer contact needed. Kept for the next
time the tree shape changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 01:05:22 +00:00
co-authored by Claude Opus 4.8
parent 12b9641adc
commit 1159c0787d
9 changed files with 582 additions and 156 deletions
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
FolderOpen,
Folder,
@@ -12,16 +12,34 @@ import {
Trash2,
FileAudio,
} from 'lucide-react';
import { useSoulseekBrowseSnapshots, useSoulseekBrowseDirs, useSoulseekBrowseFiles } from './useSoulseekBrowse';
import { formatSize, formatWhen, type SoulseekBrowseSnapshot } from './shared';
import {
useSoulseekBrowseSnapshots,
useSoulseekBrowseLevel,
useSoulseekBrowseSearch,
useSoulseekBrowseFiles,
} 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.
// 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 pages.
// 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.
const PAGE_SIZE = 100;
// 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 {
@@ -57,32 +75,41 @@ export const BrowseStateChip = ({ snapshot }: { snapshot: SoulseekBrowseSnapshot
);
};
/** 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 [page, setPage] = useState(0);
const [openDir, setOpenDir] = useState<number | null>(null);
const q = useDebounced(filter.trim(), 250);
const snapshot = snapshotOf(username);
// Folder rows survive a failed or in-flight refresh, so drive the list off the data existing rather
// 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 { data, isFetching } = useSoulseekBrowseDirs({ username, q, page, pageSize: PAGE_SIZE, enabled: hasCache });
const roots = useSoulseekBrowseLevel({ username, parent: null, limit: LEVEL_LIMIT, enabled: hasCache && !q });
const search = useSoulseekBrowseSearch({ username, q, enabled: hasCache });
// A new peer or a new filter invalidates the current page number and any expanded folder.
useEffect(() => {
setPage(0);
setOpenDir(null);
}, [username, q]);
useEffect(() => {
setFilter('');
}, [username]);
const total = data?.total ?? 0;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
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-white/10 bg-zinc-950">
@@ -160,68 +187,40 @@ export const SharesBrowser = ({ username }: { username: string }) => {
value={filter}
onChange={(ev) => setFilter(ev.target.value)}
placeholder="Filter folders…"
className="h-7 min-w-0 flex-1 bg-transparent text-sm text-zinc-100 placeholder:text-zinc-600 outline-none"
className="h-7 min-w-0 flex-1 bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-600"
/>
{isFetching && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-zinc-500" />}
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{total.toLocaleString()} matching</span>
{busy && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-zinc-500" />}
{q && (
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
{(search.data?.total ?? 0).toLocaleString()} matching
</span>
)}
</div>
{total === 0 ? (
<p className="px-4 py-6 text-center text-sm text-zinc-500">No folders match {q}.</p>
) : (
<div className="divide-y divide-white/5">
{data?.dirs.map((dir) => (
<div key={dir.id}>
<button
type="button"
onClick={() => setOpenDir((cur) => (cur === dir.id ? null : dir.id))}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors hover:bg-white/[0.03]"
>
{openDir === dir.id ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
)}
<span className="min-w-0 flex-1 truncate text-zinc-200" title={dir.name}>
{dir.name}
</span>
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
{dir.fileCount} file{dir.fileCount === 1 ? '' : 's'} · {formatSize(dir.totalSize)}
</span>
</button>
{openDir === dir.id && <DirFiles username={username} dirId={dir.id} />}
</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-white/10 bg-amber-500/5 px-4 py-1.5 text-xs text-amber-300/80">
Showing the first {search.data.matched.length.toLocaleString()} of {search.data.total.toLocaleString()}{' '}
matches narrow the filter to see the rest.
</div>
)}
{pages > 1 && (
<div className="flex items-center justify-between border-t border-white/10 px-4 py-2 text-xs text-zinc-500">
<span className="tabular-nums">
{(page * PAGE_SIZE + 1).toLocaleString()}{Math.min(total, (page + 1) * PAGE_SIZE).toLocaleString()}{' '}
of {total.toLocaleString()}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-md px-2 py-1 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
Previous
</button>
<span className="tabular-nums">
{page + 1} / {pages.toLocaleString()}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
disabled={page >= pages - 1}
className="rounded-md px-2 py-1 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
Next
</button>
</div>
{q && search.data && search.data.total === 0 ? (
<p className="px-4 py-6 text-center text-sm text-zinc-500">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>
)}
@@ -237,26 +236,137 @@ export const SharesBrowser = ({ username }: { username: string }) => {
);
};
type DirFilesProps = { username: string; dirId: number };
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 DirFiles = ({ username, dirId }: DirFilesProps) => {
const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodeProps) => {
const [open, setOpen] = useState(initialOpen);
const [limit, setLimit] = useState(LEVEL_LIMIT);
const expandable = node.childCount > 0 || node.fileCount > 0;
const level = useSoulseekBrowseLevel({
username,
parent: node.name,
limit,
enabled: !filtered && open && node.childCount > 0,
});
const children = filtered ? (filtered.childrenOf.get(node.name) ?? []) : (level.data?.nodes ?? []);
const shown = filtered ? children.length : (level.data?.total ?? 0);
const isMatch = filtered?.matched.has(node.name) ?? false;
return (
<div>
<button
type="button"
onClick={() => expandable && setOpen((v) => !v)}
style={{ paddingLeft: 12 + indent * 14 }}
className={`flex w-full items-center gap-1.5 py-1.5 pr-4 text-left text-sm transition-colors hover:bg-white/[0.03] ${
expandable ? '' : 'cursor-default'
}`}
>
{expandable ? (
open ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
)
) : (
<span className="h-3.5 w-3.5 shrink-0" />
)}
{open ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
)}
<span
className={`min-w-0 flex-1 truncate ${isMatch ? 'font-medium text-primary' : 'text-zinc-200'}`}
title={node.name}
>
{node.label}
</span>
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
{node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `}
{node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)}
</span>
</button>
{open && (
<>
{!filtered && level.isPending && node.childCount > 0 && (
<p
style={{ paddingLeft: 12 + (indent + 1) * 14 }}
className="flex items-center gap-1.5 py-1 text-xs text-zinc-500"
>
<Loader2 className="h-3 w-3 animate-spin" /> Loading folders
</p>
)}
{children.map((child) => (
<TreeNode
key={child.id}
username={username}
node={child}
filtered={filtered}
indent={indent + 1}
initialOpen={!!filtered}
/>
))}
{/* Wide levels load in one go rather than paging — there's nothing to leaf through inside a folder. */}
{!filtered && shown > children.length && children.length > 0 && (
<button
type="button"
onClick={() => setLimit(Math.min(shown, LEVEL_LIMIT_MAX))}
style={{ paddingLeft: 12 + (indent + 1) * 14 }}
className="py-1 text-xs text-primary/80 transition hover:text-primary"
>
Show all {shown.toLocaleString()} folders
</button>
)}
{filtered && node.childCount > children.length && (
<p style={{ paddingLeft: 12 + (indent + 1) * 14 }} className="py-1 text-xs text-zinc-600">
{(node.childCount - children.length).toLocaleString()} more folders here don't match the filter.
</p>
)}
{node.fileCount > 0 && <DirFiles username={username} dirId={node.id} indent={indent + 1} />}
</>
)}
</div>
);
};
type DirFilesProps = { username: string; dirId: number; indent: number };
const DirFiles = ({ username, dirId, indent }: DirFilesProps) => {
const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId);
const pad = { paddingLeft: 12 + indent * 14 };
if (isPending)
return (
<div className="flex items-center gap-2 bg-black/20 px-4 py-2 pl-10 text-xs text-zinc-500">
<p style={pad} className="flex items-center gap-1.5 py-1 text-xs text-zinc-500">
<Loader2 className="h-3 w-3 animate-spin" /> Loading files
</div>
</p>
);
if (error) return <p className="bg-black/20 px-4 py-2 pl-10 text-xs text-red-400">Could not load this folder.</p>;
if (!data?.length) return <p className="bg-black/20 px-4 py-2 pl-10 text-xs text-zinc-500">Empty folder.</p>;
if (error)
return (
<p style={pad} className="py-1 text-xs text-red-400">
Could not load this folder.
</p>
);
if (!data?.length) return null;
return (
<div className="max-h-72 overflow-y-auto bg-black/20">
<div className="max-h-72 overflow-y-auto">
{data.map((file) => (
<div key={file.name} className="flex items-center gap-2 px-4 py-1.5 pl-10 text-xs">
<div key={file.name} style={pad} className="flex items-center gap-1.5 py-1 pr-4 text-xs">
<span className="h-3.5 w-3.5 shrink-0" />
<FileAudio className="h-3 w-3 shrink-0 text-zinc-600" />
<span className="min-w-0 flex-1 truncate text-zinc-300" title={file.name}>
<span className="min-w-0 flex-1 truncate text-zinc-400" title={file.name}>
{file.name}
</span>
<span className="shrink-0 tabular-nums text-zinc-500">{formatSize(file.size)}</span>
@@ -202,8 +202,29 @@ export type SoulseekBrowseSnapshot = {
startedAt: string;
completedAt: string | null;
};
export type SoulseekBrowseDir = { id: number; name: string; fileCount: number; totalSize: number };
export type SoulseekBrowseDirPage = { dirs: SoulseekBrowseDir[]; total: number };
// A node of the stored tree. The sidecar links slskd's flat backslash paths up at ingest, so `label` is
// already just the last segment and the subtree rollups are already summed — this side never derives
// either, which is the point: a collapsed row can show what's underneath it without fetching it.
export type SoulseekBrowseNode = {
id: number;
name: string;
parentPath: string | null;
label: string;
depth: number;
childCount: number;
fileCount: number;
totalSize: number;
subtreeFileCount: number;
subtreeSize: number;
};
export type SoulseekBrowseLevel = { nodes: SoulseekBrowseNode[]; total: number };
/** A filtered tree: the matches plus every ancestor holding them up, so the shape survives filtering. */
export type SoulseekBrowseSearch = {
nodes: SoulseekBrowseNode[];
matched: string[];
total: number;
truncated: boolean;
};
// Browse carries no bitrate/duration (real peers send empty `attributes`), so unlike a search result
// there's no quality metadata to show — just name, size, extension.
export type SoulseekBrowsedFile = { name: string; size: number; extension: string };
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { SoulseekBrowseSnapshot, SoulseekBrowseDirPage, SoulseekBrowsedFile } from './shared';
import type { SoulseekBrowseSnapshot, SoulseekBrowseLevel, SoulseekBrowseSearch, SoulseekBrowsedFile } from './shared';
// Client for the sidecar's cached share trees. The expensive work happens server-side and outlives the
// tab, so this hook only ever starts a fetch and polls for its status — it never pulls a whole tree.
@@ -72,23 +72,43 @@ export function useSoulseekBrowseSnapshots() {
};
}
type DirsParams = { username: string | null; q: string; page: number; pageSize: number; enabled?: boolean };
type LevelParams = { username: string | null; parent: string | null; limit: number; enabled?: boolean };
/** One page of a peer's cached folders. Keeps the previous page visible while the next loads. */
export function useSoulseekBrowseDirs({ username, q, page, pageSize, enabled = true }: DirsParams) {
/**
* One level of a peer's tree — the children of `parent`, or the roots when it's null. Cached per level,
* so collapsing and reopening a folder is free, and expanding a shallow node never touches its subtree.
*/
export function useSoulseekBrowseLevel({ username, parent, limit, enabled = true }: LevelParams) {
const { get } = useClient();
const offset = page * pageSize;
return useQuery({
queryKey: ['soulseek', 'browse', username, 'dirs', q, offset, pageSize],
queryKey: ['soulseek', 'browse', username, 'level', parent, limit],
queryFn: () => {
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (q) params.set('q', q);
return get<SoulseekBrowseDirPage>(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs?${params}`);
const params = new URLSearchParams({ limit: String(limit) });
if (parent) params.set('parent', parent);
return get<SoulseekBrowseLevel>(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/tree?${params}`);
},
enabled: enabled && !!username,
staleTime: 5 * 60_000,
});
}
type SearchParams = { username: string | null; q: string; enabled?: boolean };
/** The filtered tree: matches with their ancestors, in one request, ready to render pre-expanded. */
export function useSoulseekBrowseSearch({ username, q, enabled = true }: SearchParams) {
const { get } = useClient();
return useQuery({
queryKey: ['soulseek', 'browse', username, 'search', q],
queryFn: () =>
get<SoulseekBrowseSearch>(
`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/tree/search?q=${encodeURIComponent(q)}`,
),
enabled: enabled && !!username && !!q,
// The previous result stays on screen while the next one loads, so the tree doesn't blink per keystroke.
placeholderData: (prev) => prev,
staleTime: 30_000,
staleTime: 60_000,
});
}