From 7d5e73cba825ae84c5bc492caf9711b9091406bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 1 Aug 2026 13:06:02 +0000 Subject: [PATCH] soulseek: let a filtered result expand back to its whole folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtering a big search is how you find an album, not just how you hide files: you type one track you know, and the folder that comes back is the one you want. But the filter had also stripped that folder down to the one track, and "download folder" then queued only that track — so finding the album and losing it were the same act. A folder now keeps its whole self alongside its matching files, and says "3 of 24". Expanding shows the album, keeps the matched track highlighted, and widens every download button in it to the full folder. A peer whose other folders matched nothing can be expanded the same way, since the album you found usually sits next to the rest of the artist. Nothing queues what isn't on screen: each button downloads exactly what is shown under it. Co-Authored-By: Claude Opus 5 --- .../src/apps/Soulseek/SearchResults.tsx | 193 ++++++++++++++---- 1 file changed, 158 insertions(+), 35 deletions(-) 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 && ( )}