diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx index 414b5770..a50695d5 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { toast } from 'sonner'; -import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X } from 'lucide-react'; +import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X, RefreshCw } from 'lucide-react'; import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards'; import { SLSKD_REFRESH_CHANNEL, @@ -21,12 +21,21 @@ import { // The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses), // never re-runs the search. Presented the way slskd does: grouped by responder (user) → folder → files, // each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus -// per-folder download actions. Grouped result is memoised per search id for the session (a proper cache -// is a later step). +// per-folder download actions. +// +// Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in +// the background. Never serve the cache alone — opening a search before its first responses land used to +// freeze that empty snapshot forever. While the search is still running we also poll, so results fill in +// live; the header's refresh re-reads on demand. const MAX_USERS = 200; +const POLL_MS = 2500; +// Safety net: a search whose completion we can't confirm shouldn't poll forever (~2 min of polling). +const MAX_POLLS = 48; const resultsCache = new Map(); +export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId); + const fileKey = (username: string, filename: string) => `${username}::${filename}`; // Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query). @@ -82,39 +91,88 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => { const cachedInit = resultsCache.get(search.id) ?? null; const [users, setUsers] = useState(cachedInit); const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [isComplete, setIsComplete] = useState(search.isComplete); // Users start expanded; toggling collapses them. const [open, setOpen] = useState>(() => new Set(cachedInit?.map((u) => u.username))); const [queued, setQueued] = useState>(new Set()); const [filters, setFilters] = useState(EMPTY_FILTERS); + // Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening + // the cards the reader deliberately collapsed. + const seenRef = useRef>(new Set(cachedInit?.map((u) => u.username) ?? [])); + const aliveRef = useRef(true); + const pollsRef = useRef(0); const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]); useEffect(() => { - const cached = resultsCache.get(search.id); - if (cached) { - setUsers(cached); - setOpen(new Set(cached.map((u) => u.username))); - return; - } - let cancelled = false; - setUsers(null); - setError(null); - client - .get(`/slskd/api/v0/searches/${search.id}/responses`) - .then((responses) => { - if (cancelled) return; + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); + + const refresh = useCallback( + async (quiet: boolean) => { + if (!quiet) setLoading(true); + 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(`/slskd/api/v0/searches/${search.id}/responses`), + client.get(`/slskd/api/v0/searches/${search.id}`).catch(() => null), + ]); + if (!aliveRef.current) return; const grouped = groupResponses(responses); resultsCache.set(search.id, grouped); setUsers(grouped); - setOpen(new Set(grouped.map((u) => u.username))); - }) - .catch((err) => !cancelled && setError(err instanceof Error ? err.message : String(err))); - return () => { - cancelled = true; - }; + setError(null); + setOpen((prev) => { + const next = new Set(prev); + for (const u of grouped) { + if (!seenRef.current.has(u.username)) { + seenRef.current.add(u.username); + next.add(u.username); + } + } + return next; + }); + if (meta) setIsComplete(meta.isComplete); + } catch (err) { + if (aliveRef.current) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (aliveRef.current) setLoading(false); + } + }, // useClient() returns a fresh object each render, so depend only on the search id. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [search.id]); + [search.id], + ); + + useEffect(() => { + const cached = resultsCache.get(search.id) ?? 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); + // 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]); + + useEffect(() => { + if (isComplete) return; + const timer = setInterval(() => { + pollsRef.current += 1; + if (pollsRef.current > MAX_POLLS) { + clearInterval(timer); + return; + } + refresh(true); + }, POLL_MS); + return () => clearInterval(timer); + }, [isComplete, refresh]); const toggle = (username: string) => setOpen((prev) => { @@ -169,7 +227,14 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
-
{search.searchText}
+
+ {search.searchText} + {!isComplete && ( + + searching + + )} +
{users === null && !error ? 'Loading results…' @@ -178,6 +243,15 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => { }${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
+ {/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */} @@ -240,7 +314,17 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => { Loading results… )} - {!error && users && users.length === 0 &&

No results stored.

} + {!error && users && users.length === 0 && ( +

+ {isComplete ? ( + 'No results stored.' + ) : ( + <> + Waiting for responses… + + )} +

+ )} {!error && users && users.length > 0 && filtered && filtered.length === 0 && (

No files match the current filters.

)} diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx index 2e02e7ae..0166c0ea 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useClient } from 'hooks/useClient'; import { toast } from 'sonner'; import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight } from 'lucide-react'; -import { SearchResults } from './SearchResults'; +import { SearchResults, dropCachedResults } from './SearchResults'; import { formatWhen, type SlskdSearchSummary } from './shared'; // The 'search' section — a search input plus the history of past searches (GET /searches). Submitting @@ -48,6 +48,7 @@ export const SearchView = () => { const remove = async (id: string) => { setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev); + dropCachedResults(id); client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load()); };