import { useState, useEffect, useCallback } from 'react'; import { Link, useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { toast } from 'sonner'; import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react'; import { SearchResults, dropCachedResults } from './SearchResults'; import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared'; import { randomId } from 'helpers/random-id'; // The 'search' section — a search input plus the history of past searches (GET /searches). Which search // is open lives in `?search=`, so rows are real links and the back button returns to the history. // // Submitting opens the new search's results straight away, the way slskd's own web UI does. That is not // only a nicety: a history row is only as fresh as the last list load, so a search you had just started // sat at 0 responses looking stalled, while the results view polls for as long as the search runs. const HISTORY_POLL_MS = 4000; // A running search nobody is watching shouldn't poll forever (~3 minutes). const MAX_HISTORY_POLLS = 45; const byNewest = (list: SlskdSearchSummary[]) => [...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt)); // slskd accepts a client-supplied id (its own UI depends on that), but don't bet the navigation on it: // prefer an id the server has actually stored, then fall back to the newest search with this text. type CreatedIdParams = { list: SlskdSearchSummary[] | null; sentId: string; returnedId?: string; text: string }; const createdSearchId = ({ list, sentId, returnedId, text }: CreatedIdParams) => { if (!list) return returnedId ?? sentId; const known = new Set(list.map((s) => s.id)); if (returnedId && known.has(returnedId)) return returnedId; if (known.has(sentId)) return sentId; return list.find((s) => s.searchText === text)?.id ?? returnedId ?? sentId; }; export const SearchView = () => { const client = useClient(); const [params, setParams] = useSearchParams(); const [query, setQuery] = useState(''); const [submitting, setSubmitting] = useState(false); const [history, setHistory] = useState(null); const [error, setError] = useState(null); const [clearing, setClearing] = useState(false); const [confirmClear, setConfirmClear] = useState(false); const selectedId = params.get(SEARCH_PARAM); const openSearch = useCallback( (id: string | null) => setParams((prev) => { const next = new URLSearchParams(prev); if (id) next.set(SEARCH_PARAM, id); else next.delete(SEARCH_PARAM); return next; }), [setParams], ); const fetchHistory = useCallback(async () => { const list = await client.get('/soulseek/api/v0/searches'); return byNewest(list ?? []); // useClient() is a fresh object each render — keep this callback stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const load = useCallback(() => { setError(null); fetchHistory() .then(setHistory) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, [fetchHistory]); // Re-read whenever the history is what's on screen — including a browser Back out of a search, which // no callback of ours sees. useEffect(() => { if (!selectedId) load(); }, [selectedId, load]); // A row's counters only move when the list is re-read, so keep re-reading while a search is running — // but not while its results are open, since that view polls on its own. const running = !!history?.some((s) => !s.isComplete); useEffect(() => { if (!running || selectedId) return; let polls = 0; const timer = setInterval(() => { polls += 1; if (polls > MAX_HISTORY_POLLS) { clearInterval(timer); return; } load(); }, HISTORY_POLL_MS); return () => clearInterval(timer); }, [running, selectedId, load]); const submit = async (ev: React.FormEvent) => { ev.preventDefault(); const text = query.trim(); if (!text || submitting) return; setSubmitting(true); const sentId = randomId(); try { const created = await client.post>('/soulseek/api/v0/searches', { id: sentId, searchText: text, }); // A failed reload shouldn't read as a failed search — fall through with what the POST told us. const list = await fetchHistory().catch(() => null); if (list) setHistory(list); setQuery(''); openSearch(createdSearchId({ list, sentId, returnedId: created?.id, text })); } catch (err) { toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`); } finally { setSubmitting(false); } }; const remove = async (id: string) => { setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev); dropCachedResults(id); if (selectedId === id) openSearch(null); client.delete(`/soulseek/api/v0/searches/${id}`).catch(() => load()); }; // slskd has no bulk delete (0.26.0 exposes only DELETE /searches/{id}), so clearing fans out one // request per search and reconciles with a reload — partial failures come back on their own. const clearAll = async () => { const ids = history?.map((s) => s.id) ?? []; if (!ids.length || clearing) return; setConfirmClear(false); setClearing(true); setHistory([]); openSearch(null); ids.forEach(dropCachedResults); const settled = await Promise.allSettled(ids.map((id) => client.delete(`/soulseek/api/v0/searches/${id}`))); const failed = settled.filter((r) => r.status === 'rejected').length; if (failed) toast.error(`${failed} of ${ids.length} searches could not be removed.`); setClearing(false); load(); }; // The destructive click is two-step rather than a modal (matching the other Soulseek panels, which // confirm nothing) — and the armed state disarms itself so it can't sit there waiting to be hit. useEffect(() => { if (!confirmClear) return; const timer = setTimeout(() => setConfirmClear(false), 4000); return () => clearTimeout(timer); }, [confirmClear]); // The row we came from, when we have it — a search opened by URL (or just created) resolves its own. const selected = selectedId ? (history?.find((s) => s.id === selectedId) ?? null) : null; const hrefFor = (id: string) => { const next = new URLSearchParams(params); next.set(SEARCH_PARAM, id); return `?${next.toString()}`; }; if (selectedId) return openSearch(null)} />; return (
{/* Header + search field */}

Search

setQuery(ev.target.value)} placeholder="Search artists, albums, tracks…" className="h-11 w-full rounded-xl border border-border bg-card pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20" />
{/* History */}
Recent searches {history && · {history.length}}
{!!history?.length && ( )}
{error &&

Could not load history: {error}

} {!error && history === null && (
Loading…
)} {!error && history?.length === 0 && (
No searches yet — try one above.
)} {history && history.length > 0 && (
    {history.map((s) => (
  • {/* The row is an anchor so it opens with cmd-click and keyboard like any link; the remove button is its sibling, never nested inside it. */}
    {s.isComplete ? : }
    {s.searchText}
    {formatWhen(s.startedAt)} {s.responseCount.toLocaleString()} {s.fileCount.toLocaleString()}
  • ))}
)}
); };