soulseek: open a search's results when you start it
A history row is only as fresh as the last list load, and the list was only read on mount — so a search you had just started sat at 0 responses looking stalled until you navigated away and back. slskd's own web UI goes straight to the search when you submit, and that view already polls while the search runs, so follow it. Which search is open now lives in ?search=<id> rather than local state: rows are real links, Back returns to the history, and a reload keeps your place. The list also keeps re-reading while any search is still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,16 +87,20 @@ const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => {
|
||||
return out;
|
||||
};
|
||||
|
||||
type SearchResultsProps = { search: SlskdSearchSummary; onBack: () => void };
|
||||
// `initial` is the history row we came from, when there is one. Opening by URL — or landing here the
|
||||
// moment a search is created — has no row yet, so the summary is fetched like everything else and the
|
||||
// header fills in a beat later.
|
||||
type SearchResultsProps = { searchId: string; initial?: SlskdSearchSummary | null; onBack: () => void };
|
||||
|
||||
export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) => {
|
||||
const client = useClient();
|
||||
const [, bumpRefresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
|
||||
const cachedInit = resultsCache.get(search.id) ?? null;
|
||||
const cachedInit = resultsCache.get(searchId) ?? null;
|
||||
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(search.isComplete);
|
||||
const [summary, setSummary] = useState<SlskdSearchSummary | null>(initial ?? null);
|
||||
const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false);
|
||||
// Users start expanded; toggling collapses them.
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
|
||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||
@@ -122,12 +126,12 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
try {
|
||||
// The summary tells us whether to keep polling; tolerate its absence rather than fail the load.
|
||||
const [responses, meta] = await Promise.all([
|
||||
client.get<SlskdResponse[]>(`/slskd/api/v0/searches/${search.id}/responses`),
|
||||
client.get<SlskdSearchSummary>(`/slskd/api/v0/searches/${search.id}`).catch(() => null),
|
||||
client.get<SlskdResponse[]>(`/slskd/api/v0/searches/${searchId}/responses`),
|
||||
client.get<SlskdSearchSummary>(`/slskd/api/v0/searches/${searchId}`).catch(() => null),
|
||||
]);
|
||||
if (!aliveRef.current) return;
|
||||
const grouped = groupResponses(responses);
|
||||
resultsCache.set(search.id, grouped);
|
||||
resultsCache.set(searchId, grouped);
|
||||
setUsers(grouped);
|
||||
setError(null);
|
||||
setOpen((prev) => {
|
||||
@@ -140,7 +144,10 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (meta) setIsComplete(meta.isComplete);
|
||||
if (meta) {
|
||||
setSummary(meta);
|
||||
setIsComplete(meta.isComplete);
|
||||
}
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -149,21 +156,23 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
},
|
||||
// useClient() returns a fresh object each render, so depend only on the search id.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[search.id],
|
||||
[searchId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = resultsCache.get(search.id) ?? null;
|
||||
const cached = resultsCache.get(searchId) ?? null;
|
||||
seenRef.current = new Set(cached?.map((u) => u.username) ?? []);
|
||||
pollsRef.current = 0;
|
||||
setUsers(cached);
|
||||
setOpen(new Set(cached?.map((u) => u.username)));
|
||||
setError(null);
|
||||
setIsComplete(search.isComplete);
|
||||
// Assume running until the summary says otherwise — a search opened straight after being created has
|
||||
// no row to tell us, and guessing "complete" would mean never polling for its first responses.
|
||||
setIsComplete(initial?.id === searchId ? initial.isComplete : false);
|
||||
// Quiet when something is already on screen — no spinner flash over usable results.
|
||||
refresh(Boolean(cached));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search.id, refresh]);
|
||||
}, [searchId, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) return;
|
||||
@@ -232,7 +241,7 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{search.searchText}</span>
|
||||
<span className="truncate text-sm font-semibold">{summary?.searchText ?? initial?.searchText ?? '…'}</span>
|
||||
{!isComplete && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> searching
|
||||
|
||||
@@ -1,46 +1,111 @@
|
||||
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, type SlskdSearchSummary } from './shared';
|
||||
import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared';
|
||||
|
||||
// The 'search' section — a search input plus the history of past searches (GET /searches). Submitting
|
||||
// creates a new search on slskd and refreshes the list. (Loading a search's results is a later step.)
|
||||
// The 'search' section — a search input plus the history of past searches (GET /searches). Which search
|
||||
// is open lives in `?search=<id>`, 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<SlskdSearchSummary[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<SlskdSearchSummary | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setError(null);
|
||||
client
|
||||
.get<SlskdSearchSummary[]>('/slskd/api/v0/searches')
|
||||
.then((list) => setHistory([...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
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<SlskdSearchSummary[]>('/slskd/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();
|
||||
}, [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 = crypto.randomUUID();
|
||||
try {
|
||||
await client.post('/slskd/api/v0/searches', { searchText: text });
|
||||
const created = await client.post<Partial<SlskdSearchSummary>>('/slskd/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('');
|
||||
load();
|
||||
openSearch(createdSearchId({ list, sentId, returnedId: created?.id, text }));
|
||||
} catch (err) {
|
||||
toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
@@ -51,6 +116,7 @@ export const SearchView = () => {
|
||||
const remove = async (id: string) => {
|
||||
setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev);
|
||||
dropCachedResults(id);
|
||||
if (selectedId === id) openSearch(null);
|
||||
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load());
|
||||
};
|
||||
|
||||
@@ -62,6 +128,7 @@ export const SearchView = () => {
|
||||
setConfirmClear(false);
|
||||
setClearing(true);
|
||||
setHistory([]);
|
||||
openSearch(null);
|
||||
ids.forEach(dropCachedResults);
|
||||
const settled = await Promise.allSettled(ids.map((id) => client.delete(`/slskd/api/v0/searches/${id}`)));
|
||||
const failed = settled.filter((r) => r.status === 'rejected').length;
|
||||
@@ -78,7 +145,15 @@ export const SearchView = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [confirmClear]);
|
||||
|
||||
if (selected) return <SearchResults search={selected} onBack={() => setSelected(null)} />;
|
||||
// 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 <SearchResults searchId={selectedId} initial={selected} onBack={() => openSearch(null)} />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
@@ -156,19 +231,13 @@ export const SearchView = () => {
|
||||
{history && history.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{history.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelected(s)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
setSelected(s);
|
||||
}
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2.5 shadow-sm transition-colors hover:bg-white/5"
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2.5 shadow-sm transition-colors hover:bg-white/5"
|
||||
>
|
||||
{/* 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. */}
|
||||
<Link to={hrefFor(s.id)} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/5 text-zinc-400">
|
||||
{s.isComplete ? <Search className="h-4 w-4" /> : <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
@@ -186,19 +255,16 @@ export const SearchView = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
remove(s.id);
|
||||
}}
|
||||
onClick={() => remove(s.id)}
|
||||
title="Remove search"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-600" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -274,6 +274,10 @@ export const SOULSEEK_USER_CHANNEL = 'soulseek:user';
|
||||
export type SoulseekUserRequest = { username: string; nonce: number };
|
||||
|
||||
// A past search, as listed by GET /searches (no responses inlined).
|
||||
// Which search's results are open — `/soulseek?search=<id>`, read from the URL rather than held in a
|
||||
// panel channel or local state. See docs/navigation-audit.md.
|
||||
export const SEARCH_PARAM = 'search';
|
||||
|
||||
export type SlskdSearchSummary = {
|
||||
id: string;
|
||||
searchText: string;
|
||||
|
||||
Reference in New Issue
Block a user