diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx index c02cea89..e3708744 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx @@ -2,7 +2,20 @@ 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, RefreshCw } from 'lucide-react'; +import { + ArrowLeft, + ChevronDown, + ChevronUp, + Download, + Lock, + Loader2, + Folder, + Zap, + Users, + Search, + X, + RefreshCw, +} from 'lucide-react'; import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards'; import { UserMenu } from './UserMenu'; import { @@ -24,6 +37,10 @@ import { // each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus // per-folder download actions. // +// Filtering is a way of *finding* a folder as much as of hiding files, so nothing it hides is out of +// reach: a matching folder expands to the whole album in place, and a matching peer expands to the rest +// of its folders. Download buttons always queue exactly what is on screen. +// // 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 @@ -56,7 +73,18 @@ const EMPTY_FILTERS: Filters = { text: '', freeOnly: false, hideLocked: false, e const filtersActive = (f: Filters) => f.text.trim() !== '' || f.freeOnly || f.hideLocked || f.exts.size > 0 || f.minBitrate !== null; -const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => { +// Filtering narrows *files*, but the reason to filter is usually to find a folder: you type one track +// name you know to pick your album out of 300 unrelated ones. So a filtered folder keeps a reference to +// the whole folder alongside its matching files, and can be expanded back to the full album in place — +// otherwise finding it would be the same act as hiding the rest of it. +type FolderMatch = { folder: ResultFolder; matched: ResultFile[]; matchedSize: number }; +type UserMatch = { user: ResultUser; folders: FolderMatch[]; matchedCount: number }; + +const folderKey = (username: string, path: string) => `${username}::${path}`; +// Same reveal set, for "this peer's folders that matched nothing" — the discography around the album. +const userKey = (username: string) => `user::${username}`; + +const applyFilters = (users: ResultUser[], f: Filters): UserMatch[] => { const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean); const pos = terms.filter((t) => !t.startsWith('-')); const neg = terms @@ -72,16 +100,16 @@ const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => { if (neg.some((t) => hay.includes(t))) return false; return true; }; - const out: ResultUser[] = []; + const out: UserMatch[] = []; for (const user of users) { if (f.freeOnly && !user.hasFreeUploadSlot) continue; - const folders: ResultFolder[] = []; + const folders: FolderMatch[] = []; for (const folder of user.folders) { - const files = folder.files.filter(fileOk); - if (files.length) folders.push({ ...folder, files, size: files.reduce((n, x) => n + x.size, 0) }); + const matched = folder.files.filter(fileOk); + if (matched.length) folders.push({ folder, matched, matchedSize: matched.reduce((n, x) => n + x.size, 0) }); } if (folders.length) { - out.push({ ...user, folders, fileCount: folders.reduce((n, fd) => n + fd.files.length, 0) }); + out.push({ user, folders, matchedCount: folders.reduce((n, fd) => n + fd.matched.length, 0) }); } } return out; @@ -105,6 +133,8 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) const [open, setOpen] = useState>(() => new Set(cachedInit?.map((u) => u.username))); const [queued, setQueued] = useState>(new Set()); const [filters, setFilters] = useState(EMPTY_FILTERS); + // Folders the reader has expanded past the filter, keyed username::path. + const [revealed, setRevealed] = useState>(new Set()); // 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) ?? [])); @@ -165,6 +195,7 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) pollsRef.current = 0; setUsers(cached); setOpen(new Set(cached?.map((u) => u.username))); + setRevealed(new Set()); setError(null); // 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. @@ -195,6 +226,14 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) return next; }); + const toggleReveal = (key: string) => + setRevealed((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + const queue = async (username: string, files: ResultFile[]) => { const usable = files.filter((f) => !f.isLocked); if (usable.length === 0) return; @@ -225,7 +264,7 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) const setMinBitrate = (n: number) => setFilters((f) => ({ ...f, minBitrate: f.minBitrate === n ? null : n })); const shown = filtered ? filtered.slice(0, MAX_USERS) : []; - const totalFiles = filtered?.reduce((n, u) => n + u.fileCount, 0) ?? 0; + const totalFiles = filtered?.reduce((n, u) => n + u.matchedCount, 0) ?? 0; const anyFilter = filtersActive(filters); return ( @@ -343,14 +382,16 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) )}
- {shown.map((user) => ( + {shown.map((match) => ( toggle(user.username)} + key={match.user.username} + match={match} + open={open.has(match.user.username)} + onToggle={() => toggle(match.user.username)} queued={queued} onDownload={queue} + revealed={revealed} + onToggleReveal={toggleReveal} /> ))}
@@ -360,15 +401,35 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) }; type UserCardProps = { - user: ResultUser; + match: UserMatch; open: boolean; onToggle: () => void; queued: Set; onDownload: (username: string, files: ResultFile[]) => void; + revealed: Set; + onToggleReveal: (key: string) => void; }; -const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) => { - const allFiles = user.folders.flatMap((f) => f.files); +const UserCard = ({ match, open, onToggle, queued, onDownload, revealed, onToggleReveal }: UserCardProps) => { + const { user, folders, matchedCount } = match; + // A peer with the album usually has more of the artist, and the filter hid all of it. Expanding brings + // the non-matching folders back, each already whole. + const allFolders = revealed.has(userKey(user.username)); + const hiddenFolders = user.folders.length - folders.length; + const visible: FolderMatch[] = allFolders + ? user.folders.map( + (folder) => + folders.find((fm) => fm.folder.path === folder.path) ?? { + folder, + matched: folder.files, + matchedSize: folder.size, + }, + ) + : folders; + // Every action queues what is on screen, expanded folders included — never the hidden remainder. + const shownFiles = visible.flatMap((fm) => + revealed.has(folderKey(user.username, fm.folder.path)) ? fm.folder.files : fm.matched, + ); const meta = ( <> {user.hasFreeUploadSlot ? ( @@ -381,7 +442,11 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) = )} {user.uploadSpeed > 0 && {formatSpeed(user.uploadSpeed)}} - {user.fileCount.toLocaleString()} files + + {matchedCount < user.fileCount + ? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files` + : `${user.fileCount.toLocaleString()} files`} + ); return ( @@ -389,22 +454,43 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) = } meta={meta} /> {open && ( - {user.folders.map((folder) => ( + {visible.map((fm) => ( onToggleReveal(folderKey(user.username, fm.folder.path))} queued={queued} onDownload={onDownload} /> ))} - {allFiles.length > 1 && ( + {hiddenFolders > 0 && ( + )} + {shownFiles.length > 1 && ( + )} @@ -415,14 +501,20 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) = type FolderBlockProps = { username: string; - folder: ResultFolder; + match: FolderMatch; + revealed: boolean; + onToggleReveal: () => void; queued: Set; onDownload: (username: string, files: ResultFile[]) => void; }; -const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps) => { +const FolderBlock = ({ username, match, revealed, onToggleReveal, queued, onDownload }: FolderBlockProps) => { + const { folder, matched, matchedSize } = match; const [open, setOpen] = useState(true); - const unlocked = folder.files.filter((f) => !f.isLocked).length; + const files = revealed ? folder.files : matched; + const hidden = folder.files.length - matched.length; + const matchedNames = revealed && hidden > 0 ? new Set(matched.map((f) => f.filename)) : null; + const unlocked = files.filter((f) => !f.isLocked).length; return ( } label={folder.label} title={folder.path} - meta={`${folder.files.length} · ${formatSize(folder.size)}`} + meta={`${hidden > 0 && !revealed ? `${files.length} of ${folder.files.length}` : files.length} · ${formatSize( + revealed ? folder.size : matchedSize, + )}`} action={ - folder.files.length > 1 ? ( + files.length > 1 ? ( + )} - {folder.files.map((file) => { + {files.map((file) => { const isQueued = queued.has(fileKey(username, file.filename)); const duration = formatDuration(file.length); + // In an expanded folder, keep the file you filtered for legible among its neighbours. + const isMatch = !matchedNames || matchedNames.has(file.filename); return ( -
+
{file.isLocked && } - {file.name} + + {file.name} +
{file.bitRate ? {file.bitRate} kbps : null} @@ -478,10 +600,11 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps) {unlocked > 1 && ( )}