soulseek: queue downloads straight out of the cached tree
a browsed folder is a path, not a file list, and what clicking one means is "everything under here" — so the expansion happens in the sidecar, off the cache, rather than making the browser walk the tree a level at a time and rebuild paths it only half knows. browse reports file names as basenames, unlike search, so the peer's real path is rejoined from the folder row. sizes come from the cache too: slskd matches a queued download on filename AND size, so a number supplied by the client would be a transfer that silently never starts. a subtree can be the peer's whole share (284k files on one measured peer), so an over-limit request is refused with its count rather than truncated into a partial download nobody asked for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
useSoulseekBrowseLevel,
|
||||
useSoulseekBrowseSearch,
|
||||
useSoulseekBrowseFiles,
|
||||
useSoulseekEnqueue,
|
||||
} from './useSoulseekBrowse';
|
||||
import { formatSize, formatWhen, type SoulseekBrowseNode, type SoulseekBrowseSnapshot } from './shared';
|
||||
|
||||
@@ -75,6 +76,21 @@ export const BrowseStateChip = ({ snapshot }: { snapshot: SoulseekBrowseSnapshot
|
||||
);
|
||||
};
|
||||
|
||||
/** Queue-this button, shown on row hover. Kept out of the row's own button — nesting the two is invalid. */
|
||||
type QueueButtonProps = { onQueue: () => void; busy: boolean; title: string };
|
||||
|
||||
const QueueButton = ({ onQueue, busy, title }: QueueButtonProps) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQueue}
|
||||
disabled={busy}
|
||||
title={title}
|
||||
className="mr-3 flex h-6 w-6 shrink-0 items-center justify-center rounded text-zinc-600 opacity-0 transition hover:bg-white/10 hover:text-primary focus:opacity-100 disabled:opacity-40 group-hover:opacity-100"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
/** The filtered tree, indexed for rendering: children by parent path, plus which rows actually matched. */
|
||||
type Filtered = { childrenOf: Map<string, SoulseekBrowseNode[]>; matched: Set<string> };
|
||||
|
||||
@@ -248,6 +264,7 @@ type TreeNodeProps = {
|
||||
const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodeProps) => {
|
||||
const [limit, setLimit] = useState(LEVEL_LIMIT);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const enqueue = useSoulseekEnqueue(username);
|
||||
|
||||
// Filtering stops at a revealed row: from here down it's ordinary lazy browsing, so opening a matched
|
||||
// folder shows what's really inside it rather than the filtered skeleton — which is why you searched.
|
||||
@@ -284,39 +301,48 @@ const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodePro
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => expandable && toggle()}
|
||||
style={{ paddingLeft: 12 + indent * 14 }}
|
||||
className={`flex w-full items-center gap-1.5 py-1.5 pr-4 text-left text-sm transition-colors hover:bg-white/[0.03] ${
|
||||
expandable ? '' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
{expandable ? (
|
||||
open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{open ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${isMatch ? 'font-medium text-primary' : 'text-zinc-200'}`}
|
||||
title={node.name}
|
||||
<div className="group flex items-center transition-colors hover:bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => expandable && toggle()}
|
||||
style={{ paddingLeft: 12 + indent * 14 }}
|
||||
className={`flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left text-sm ${
|
||||
expandable ? '' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `}
|
||||
{node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)}
|
||||
</span>
|
||||
</button>
|
||||
{expandable ? (
|
||||
open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{open ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${isMatch ? 'font-medium text-primary' : 'text-zinc-200'}`}
|
||||
title={node.name}
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `}
|
||||
{node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)}
|
||||
</span>
|
||||
</button>
|
||||
{node.subtreeFileCount > 0 && (
|
||||
<QueueButton
|
||||
onQueue={() => enqueue.mutate({ path: node.name })}
|
||||
busy={enqueue.isPending}
|
||||
title={`Queue all ${node.subtreeFileCount.toLocaleString()} files below ${node.label}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
@@ -355,17 +381,18 @@ const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodePro
|
||||
Showing {children.length.toLocaleString()} of {total.toLocaleString()} folders.
|
||||
</p>
|
||||
)}
|
||||
{node.fileCount > 0 && <DirFiles username={username} dirId={node.id} indent={indent + 1} />}
|
||||
{node.fileCount > 0 && <DirFiles username={username} dirId={node.id} path={node.name} indent={indent + 1} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DirFilesProps = { username: string; dirId: number; indent: number };
|
||||
type DirFilesProps = { username: string; dirId: number; path: string; indent: number };
|
||||
|
||||
const DirFiles = ({ username, dirId, indent }: DirFilesProps) => {
|
||||
const DirFiles = ({ username, dirId, path, indent }: DirFilesProps) => {
|
||||
const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId);
|
||||
const enqueue = useSoulseekEnqueue(username);
|
||||
const pad = { paddingLeft: 12 + indent * 14 };
|
||||
|
||||
if (isPending)
|
||||
@@ -385,13 +412,22 @@ const DirFiles = ({ username, dirId, indent }: DirFilesProps) => {
|
||||
return (
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{data.map((file) => (
|
||||
<div key={file.name} style={pad} className="flex items-center gap-1.5 py-1 pr-4 text-xs">
|
||||
<div
|
||||
key={file.name}
|
||||
style={pad}
|
||||
className="group flex items-center gap-1.5 py-1 text-xs transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<span className="h-3.5 w-3.5 shrink-0" />
|
||||
<FileAudio className="h-3 w-3 shrink-0 text-zinc-600" />
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-400" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-zinc-500">{formatSize(file.size)}</span>
|
||||
<QueueButton
|
||||
onQueue={() => enqueue.mutate({ path, file: file.name })}
|
||||
busy={enqueue.isPending}
|
||||
title={`Queue ${file.name}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { SoulseekBrowseSnapshot, SoulseekBrowseLevel, SoulseekBrowseSearch, SoulseekBrowsedFile } from './shared';
|
||||
|
||||
@@ -112,6 +113,27 @@ export function useSoulseekBrowseSearch({ username, q, enabled = true }: SearchP
|
||||
});
|
||||
}
|
||||
|
||||
/** A folder (its whole subtree) or one file inside it, named the way the cache stores them. */
|
||||
export type SoulseekDownloadTarget = { path: string; file?: string };
|
||||
|
||||
/**
|
||||
* Queue a download from the cache. The sidecar expands a folder into its files and rejoins each one to
|
||||
* its peer path, so this sends a path rather than a list — the browser never walks the subtree itself.
|
||||
*/
|
||||
export function useSoulseekEnqueue(username: string) {
|
||||
const { post } = useClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (target: SoulseekDownloadTarget) =>
|
||||
post<{ queued: number }>(`/slskd/_officer/browse/${encodeURIComponent(username)}/download`, target),
|
||||
onSuccess: ({ queued }, target) =>
|
||||
toast.success(queued === 1 ? `Queued ${target.file ?? basename(target.path)}` : `Queued ${queued} files`),
|
||||
onError: (err) => toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`),
|
||||
});
|
||||
}
|
||||
|
||||
const basename = (path: string) => path.slice(path.lastIndexOf('\\') + 1);
|
||||
|
||||
/** A single folder's files, fetched only when it's expanded. Cached indefinitely — the rows are immutable. */
|
||||
export function useSoulseekBrowseFiles(username: string | null, dirId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
Reference in New Issue
Block a user