import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useClient } from 'hooks/useClient'; 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. const SNAPSHOTS_KEY = ['soulseek', 'browse'] as const; const EMPTY: SoulseekBrowseSnapshot[] = []; // While something is fetching we need to notice it finishing; otherwise this data barely changes. const POLL_MS = 3000; /** * Every peer's cache state in one request, so the favourites list can badge each row without N queries. * Polls only while at least one fetch is in flight. */ export function useSoulseekBrowseSnapshots() { const { get, post, delete: del } = useClient(); const qc = useQueryClient(); const { data } = useQuery({ queryKey: SNAPSHOTS_KEY, queryFn: () => get('/soulseek/_officer/browse'), staleTime: 10_000, refetchInterval: (query) => (query.state.data?.some((s) => s.status === 'pending') ? POLL_MS : false), }); const snapshots = data ?? EMPTY; const snapshotOf = (username: string) => snapshots.find((s) => s.username === username) ?? null; const start = useMutation({ mutationFn: (username: string) => post(`/soulseek/_officer/browse/${encodeURIComponent(username)}`, {}), // Flip to pending immediately so the row shows a spinner before the first poll lands. onMutate: (username) => { const prev = qc.getQueryData(SNAPSHOTS_KEY) ?? EMPTY; const existing = prev.find((s) => s.username === username); const pending: SoulseekBrowseSnapshot = { ...(existing ?? { username, directoryCount: 0, fileCount: 0, totalSize: 0, completedAt: null, }), status: 'pending', error: null, startedAt: new Date().toISOString(), }; qc.setQueryData( SNAPSHOTS_KEY, existing ? prev.map((s) => (s.username === username ? pending : s)) : [...prev, pending], ); return { prev }; }, onError: (_err, _username, ctx) => { if (ctx?.prev) qc.setQueryData(SNAPSHOTS_KEY, ctx.prev); }, onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }), }); const drop = useMutation({ mutationFn: (username: string) => del(`/soulseek/_officer/browse/${encodeURIComponent(username)}`), onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }), }); return { snapshots, snapshotOf, fetchShares: (username: string) => start.mutate(username), dropShares: (username: string) => drop.mutate(username), }; } type LevelParams = { username: string | null; parent: string | null; limit: number; enabled?: boolean }; /** * 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(); return useQuery({ queryKey: ['soulseek', 'browse', username, 'level', parent, limit], queryFn: () => { const params = new URLSearchParams({ limit: String(limit) }); if (parent) params.set('parent', parent); return get(`/soulseek/_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( `/soulseek/_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: 60_000, }); } /** A folder (its whole subtree) or one file inside it, named the way the cache stores them. */ export type SoulseekDownloadTarget = { path: string; file?: string }; /** * Queue a download from the cache. The sidecar expands a folder into its files and rejoins each one to * its peer path, so this sends a path rather than a list — the browser never walks the subtree itself. */ export function useSoulseekEnqueue(username: string) { const { post } = useClient(); return useMutation({ mutationFn: (target: SoulseekDownloadTarget) => post<{ queued: number }>(`/soulseek/_officer/browse/${encodeURIComponent(username)}/download`, target), onSuccess: ({ queued }, target) => toast.success(queued === 1 ? `Queued ${target.file ?? basename(target.path)}` : `Queued ${queued} files`), onError: (err) => toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`), }); } const basename = (path: string) => path.slice(path.lastIndexOf('\\') + 1); /** A single folder's files, fetched only when it's expanded. Cached indefinitely — the rows are immutable. */ export function useSoulseekBrowseFiles(username: string | null, dirId: number | null) { const { get } = useClient(); return useQuery({ queryKey: ['soulseek', 'browse', username, 'dir', dirId], queryFn: () => get(`/soulseek/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`), enabled: !!username && dirId !== null, staleTime: Infinity, }); }