import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Box, Text, useInput } from "ink"; import { useStore, CATEGORIES } from "../store"; import { Spinner } from "./Spinner"; import { SearchBar } from "./SearchBar"; import { Panel } from "./Panel"; import { Rule } from "./Rule"; import { useConcurrentSearch } from "../hooks/useConcurrentSearch"; import { getSource, SOURCES } from "../../sources/registry"; import { wrapStep, windowStart } from "../move"; import { sortResults, nextSort, sortLabel, sortArrow, type Sort, type SortField } from "../sort"; import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme"; import { cleanText, formatBytes, formatRelative, truncate } from "../../util/format"; import type { Source, TorrentResult } from "../../sources/types"; type Mode = "list" | "search" | "detail"; const PLACEHOLDER = "Search or paste a magnet link…"; function DetailRow({ label, value }: { label: string; value: ReactNode }) { return ( {label} {value} ); } function Detail({ r, width }: { r: TorrentResult; width: number }) { const ss = SOURCE_STYLE[r.source]; const date = formatRelative(r.added); const health = r.seeders || r.leechers ? ( 0 ? COLOR.good : undefined} bold={r.seeders > 0}> {r.seeders} {` seeders ${ICON.dot} ${r.leechers} leechers`} ) : ( unknown ); return ( {cleanText(r.name)} {ss.tag} 0 ? ( {formatBytes(r.sizeBytes)} ) : ( unknown ) } /> {r.numFiles ? ( {String(r.numFiles)}} /> ) : null} {date ? {date}} /> : null} {r.infoHash} } /> {r.magnet} } /> d Download {` ${ICON.dot} `} y Copy magnet {` ${ICON.dot} `} esc back ); } export function Results() { const { query, submitQuery, section, region, setRegion, setCaptureMode, startDownload, copyMagnet, contentWidth, listRows, } = useStore(); const search = useConcurrentSearch(query); const [sort, setSort] = useState("none"); const results = useMemo(() => { const cat = CATEGORIES.find((c) => c.key === section); const base = cat?.group ? search.results.filter((r) => getSource(r.source).group === cat.group) : search.results; return sortResults(base, sort); }, [search.results, section, sort]); const focused = region === "content"; const [mode, setMode] = useState("list"); const [cursor, setCursor] = useState(0); const [detail, setDetail] = useState(null); useEffect(() => { setCursor(0); }, [results]); useEffect(() => { if (!focused) return; setCaptureMode(mode === "search" ? "text" : mode === "detail" ? "esc" : "none"); return () => setCaptureMode("none"); }, [mode, focused, setCaptureMode]); useEffect(() => { if (!focused) setMode("list"); }, [focused]); const clamped = Math.min(cursor, Math.max(0, results.length - 1)); const searchH = 3; const panelOuter = Math.max(5, listRows - searchH - 1); const listHeight = Math.max(3, panelOuter - 4); const pageJump = Math.max(1, listHeight - 1); const openDownload = (r: TorrentResult): void => startDownload({ id: r.infoHash, name: r.name, magnet: r.magnet, source: r.source, sizeBytes: r.sizeBytes, }); const copyResultMagnet = (r: TorrentResult): void => copyMagnet({ name: r.name, magnet: r.magnet }); useInput( (input, key) => { if (input === "/") { setMode("search"); return; } if (key.upArrow) { if (results.length > 0 && clamped > 0) setCursor(clamped - 1); else setMode("search"); return; } if (results.length === 0) return; if (key.downArrow) setCursor(wrapStep(clamped, 1, results.length)); else if (key.pageUp) setCursor(Math.max(0, clamped - pageJump)); else if (key.pageDown) setCursor(Math.min(results.length - 1, clamped + pageJump)); else if (key.return) { const r = results[clamped]; if (r) { setDetail(r); setMode("detail"); } } else if (input === "d") { const r = results[clamped]; if (r) openDownload(r); } else if (input === "y") { const r = results[clamped]; if (r) copyResultMagnet(r); } else if (input === "s") { setSort((cur) => nextSort(cur)); } }, { isActive: focused && mode === "list" }, ); useInput( (input, key) => { if (key.escape) { setMode("list"); setDetail(null); } else if (input === "d" && detail) openDownload(detail); else if (input === "y" && detail) copyResultMagnet(detail); }, { isActive: focused && mode === "detail" }, ); useInput( (_input, key) => { if (key.escape) setMode("list"); }, { isActive: focused && mode === "search" }, ); const onSubmit = (value: string): void => { setMode("list"); submitQuery(value); }; const browsing = query.trim() === ""; const erroredCount = useMemo( () => Object.values(search.perSource).filter((s) => s.error).length, [search.perSource], ); const activeCat = CATEGORIES.find((c) => c.key === section); const tabSources = activeCat?.group ? SOURCES.filter((s) => s.group === activeCat.group) : SOURCES; const tabErrored = tabSources.length > 0 && tabSources.every((s) => search.perSource[s.id]?.error); const showStats = useMemo( () => results.some((r) => r.sizeBytes > 0 || r.seeders > 0), [results], ); const numW = Math.max(2, String(results.length).length); const outageCodes = (sources: readonly Source[]): string => { const codes = [ ...new Set(sources.map((s) => search.perSource[s.id]?.code).filter(Boolean)), ]; return codes.length ? ` (${codes.join(", ")})` : ""; }; const sortNote = sort === "none" ? "" : ` ${ICON.dot} sort: ${sortLabel(sort)}`; const status = () => { if (search.loading) { if (results.length > 0) return {`searching… ${search.done}/${search.total} sources${sortNote}`}; return ( ); } if (results.length === 0) { if (erroredCount >= search.total) { const downAll = SOURCES.filter((s) => search.perSource[s.id]?.error); return ( {`Couldn't reach any source. They may be down${outageCodes(downAll)}.`} ); } if (tabErrored && activeCat) { const down = tabSources.filter((s) => search.perSource[s.id]?.error); const who = down.length === 1 ? "The source" : `All ${down.length} sources`; return ( {`Couldn't reach ${activeCat.label}. ${who} may be down${outageCodes(down)}.`} ); } if (search.results.length > 0 && activeCat?.group) return {`No ${activeCat.label.toLowerCase()} results yet. Try another tab or a search.`}; return ( {browsing ? "Nothing new right now." : `No results for "${truncate(query, 28)}".`} ); } const note = erroredCount > 0 ? ` (${erroredCount} source${erroredCount === 1 ? "" : "s"} down)` : ""; const head = browsing ? "newest across all sources" : `${results.length} result${results.length === 1 ? "" : "s"}`; return {`${head}${note}${sortNote}`}; }; const sortMark = (field: SortField, label: string): ReactNode => { if (sort === "none" || sort.field !== field) return label; return ( <> {sortArrow(sort.dir)} {label} ); }; const start = windowStart(clamped, results.length, listHeight); const visible = results.slice(start, start + listHeight); const count = results.length > 0 ? `(${results.length})` : undefined; return ( setMode("list")} onExitLeft={() => setRegion("sidebar")} /> {mode === "detail" && detail ? ( ) : ( <> {status()} 0 ? 1 : 0}> {results.length > 0 ? ( # Name {showStats ? ( <> {sortMark("size", "Size")} {sortMark("seeders", "Seed:Lch")} ) : ( Added )} {sortMark("source", "Src")} ) : null} {visible.map((r, i) => { const index = start + i; const here = index === clamped && focused && mode === "list"; const ss = SOURCE_STYLE[r.source]; return ( {here ? ICON.pointer : ""} {index + 1} {cleanText(r.name)} {showStats ? ( <> {r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"} 0 ? COLOR.good : undefined} dimColor={r.seeders === 0}> {r.seeders || r.leechers ? `${r.seeders}:${r.leechers}` : "-"} ) : ( {formatRelative(r.added) || "-"} )} {ss.tag} ); })} )} ); }