soulseek: let a filtered result expand back to its whole folder

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 13:06:02 +00:00
co-authored by Claude Opus 5
parent 93bf617151
commit 7d5e73cba8
@@ -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<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
const [queued, setQueued] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
// Folders the reader has expanded past the filter, keyed username::path.
const [revealed, setRevealed] = useState<Set<string>>(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<Set<string>>(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)
)}
<div className="flex flex-col gap-2">
{shown.map((user) => (
{shown.map((match) => (
<UserCard
key={user.username}
user={user}
open={open.has(user.username)}
onToggle={() => 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}
/>
))}
</div>
@@ -360,15 +401,35 @@ export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps)
};
type UserCardProps = {
user: ResultUser;
match: UserMatch;
open: boolean;
onToggle: () => void;
queued: Set<string>;
onDownload: (username: string, files: ResultFile[]) => void;
revealed: Set<string>;
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) =
</span>
)}
{user.uploadSpeed > 0 && <span>{formatSpeed(user.uploadSpeed)}</span>}
<span className="tabular-nums">{user.fileCount.toLocaleString()} files</span>
<span className="tabular-nums">
{matchedCount < user.fileCount
? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files`
: `${user.fileCount.toLocaleString()} files`}
</span>
</>
);
return (
@@ -389,22 +454,43 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) =
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
{open && (
<CardBody>
{user.folders.map((folder) => (
{visible.map((fm) => (
<FolderBlock
key={folder.path}
key={fm.folder.path}
username={user.username}
folder={folder}
match={fm}
revealed={revealed.has(folderKey(user.username, fm.folder.path))}
onToggleReveal={() => onToggleReveal(folderKey(user.username, fm.folder.path))}
queued={queued}
onDownload={onDownload}
/>
))}
{allFiles.length > 1 && (
{hiddenFolders > 0 && (
<button
type="button"
onClick={() => onDownload(user.username, allFiles)}
onClick={() => onToggleReveal(userKey(user.username))}
className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed border-white/10 py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
>
{allFolders ? (
<>
<ChevronUp className="h-3.5 w-3.5" /> Show only the {folders.length} matching{' '}
{folders.length === 1 ? 'folder' : 'folders'}
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" /> Show this peer's other {hiddenFolders}{' '}
{hiddenFolders === 1 ? 'folder' : 'folders'}
</>
)}
</button>
)}
{shownFiles.length > 1 && (
<button
type="button"
onClick={() => onDownload(user.username, shownFiles)}
className="flex items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.02] py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
>
<Download className="h-3.5 w-3.5" /> Download all ({allFiles.filter((f) => !f.isLocked).length})
<Download className="h-3.5 w-3.5" /> Download all ({shownFiles.filter((f) => !f.isLocked).length})
</button>
)}
</CardBody>
@@ -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<string>;
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 (
<SubCard>
<SubCardHeader
@@ -431,13 +523,15 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps)
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
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 ? (
<button
type="button"
onClick={() => onDownload(username, folder.files)}
title="Download folder"
onClick={() => onDownload(username, files)}
title={revealed || hidden === 0 ? 'Download folder' : `Download the ${files.length} matching files`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100"
>
<Download className="h-3.5 w-3.5" />
@@ -447,15 +541,43 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps)
/>
{open && (
<>
{/* The point of the whole exercise: the track you filtered for has found the album, so open the
album. Revealing also widens every download button below to the full folder. */}
{hidden > 0 && (
<button
type="button"
onClick={onToggleReveal}
className="flex w-full items-center justify-center gap-1.5 border-b border-white/5 bg-white/[0.02] py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
>
{revealed ? (
<>
<ChevronUp className="h-3.5 w-3.5" /> Show only the {matched.length} matching
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" /> Show the whole folder ({folder.files.length} files)
</>
)}
</button>
)}
<RowList>
{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 (
<div key={file.filename} className="flex items-center gap-3 px-3 py-1.5 pl-8 text-sm text-zinc-200">
<div
key={file.filename}
className={`flex items-center gap-3 px-3 py-1.5 pl-8 text-sm ${
isMatch ? 'text-zinc-200' : 'text-zinc-400'
}`}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
{file.isLocked && <Lock className="h-3 w-3 shrink-0 text-zinc-500" />}
<span className="truncate">{file.name}</span>
<span className={`truncate ${isMatch && matchedNames ? 'font-medium text-zinc-100' : ''}`}>
{file.name}
</span>
</span>
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-zinc-500">
{file.bitRate ? <span>{file.bitRate} kbps</span> : null}
@@ -478,10 +600,11 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps)
{unlocked > 1 && (
<button
type="button"
onClick={() => onDownload(username, folder.files)}
onClick={() => onDownload(username, files)}
className="flex w-full items-center justify-center gap-1.5 border-t border-white/5 py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
>
<Download className="h-3.5 w-3.5" /> Download folder ({unlocked})
<Download className="h-3.5 w-3.5" />
{revealed || hidden === 0 ? `Download folder (${unlocked})` : `Download ${unlocked} matching`}
</button>
)}
</>