diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index 43b9d34..7f150fa 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -8,6 +8,7 @@ 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"; @@ -123,11 +124,14 @@ export function Results() { const search = useConcurrentSearch(query); + const [sort, setSort] = useState("none"); const results = useMemo(() => { const cat = CATEGORIES.find((c) => c.key === section); - if (!cat?.group) return search.results; - return search.results.filter((r) => getSource(r.source).group === cat.group); - }, [search.results, 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"); @@ -194,6 +198,8 @@ export function Results() { } else if (input === "y") { const r = results[clamped]; if (r) copyResultMagnet(r); + } else if (input === "s") { + setSort((cur) => nextSort(cur)); } }, { isActive: focused && mode === "list" }, @@ -282,7 +288,18 @@ export function Results() { const head = browsing ? "newest across all sources" : `${results.length} result${results.length === 1 ? "" : "s"}`; - return {head + note}; + const sortNote = sort === "none" ? "" : ` ${ICON.dot} sort: ${sortLabel(sort)}`; + 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); @@ -326,10 +343,10 @@ export function Results() { {showStats ? ( <> - Size + {sortMark("size", "Size")} - Seed:Lch + {sortMark("seeders", "Seed:Lch")} ) : ( @@ -338,7 +355,7 @@ export function Results() { )} - Src + {sortMark("source", "Src")} ) : null} diff --git a/src/ui/hooks/useConcurrentSearch.ts b/src/ui/hooks/useConcurrentSearch.ts index 9f61fa5..061fc25 100644 --- a/src/ui/hooks/useConcurrentSearch.ts +++ b/src/ui/hooks/useConcurrentSearch.ts @@ -42,13 +42,6 @@ function dedupe(list: TorrentResult[]): TorrentResult[] { return [...byHash.values()]; } -function sortResults(list: TorrentResult[]): TorrentResult[] { - return list.sort((a, b) => { - if (b.seeders !== a.seeders) return b.seeders - a.seeders; - return (b.added ?? 0) - (a.added ?? 0); - }); -} - function idleState(): ConcurrentSearchState { return { results: [], @@ -105,7 +98,7 @@ export function useConcurrentSearch(query: string): ConcurrentSearchState { if (!alive) return; done += 1; setState({ - results: sortResults(dedupe(collected.slice())), + results: dedupe(collected.slice()), perSource: { ...per }, loading: done < SOURCES.length, done, diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index a42ac0a..436ce11 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -27,6 +27,7 @@ export const HELP_GROUPS: HelpGroup[] = [ hints: [ { keys: "/", label: "Edit search" }, { keys: "↵", label: "Run search" }, + { keys: "s", label: "Sort results" }, { keys: "y", label: "Copy magnet" }, { keys: "m", label: "Paste magnet" }, ], @@ -101,6 +102,7 @@ export function footerHints( return [ NAVIGATE, { keys: "d", label: "Download" }, + { keys: "s", label: "Sort" }, { keys: "y", label: "Copy magnet" }, { keys: "/", label: "Search" }, { keys: "m", label: "Paste magnet" }, diff --git a/src/ui/sort.test.ts b/src/ui/sort.test.ts new file mode 100644 index 0000000..d577b73 --- /dev/null +++ b/src/ui/sort.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { nextSort, sortResults, sortArrow, SORT_CYCLE } from "./sort"; +import type { Sort } from "./sort"; +import type { SourceId, TorrentResult } from "../sources/types"; + +function r(p: Partial & { infoHash: string }): TorrentResult { + return { + name: p.name ?? p.infoHash, + sizeBytes: p.sizeBytes ?? 0, + seeders: p.seeders ?? 0, + leechers: p.leechers ?? 0, + source: (p.source ?? "yts") as SourceId, + magnet: p.magnet ?? `magnet:?xt=urn:btih:${p.infoHash}`, + ...p, + }; +} + +const ids = (list: TorrentResult[]): string[] => list.map((x) => x.infoHash); + +describe("nextSort", () => { + it("cycles through 7 states: none -> size asc/desc -> seeders asc/desc -> source asc/desc -> none", () => { + const seq: Sort[] = []; + let s: Sort = "none"; + for (let i = 0; i < 7; i++) { + s = nextSort(s); + seq.push(s); + } + expect(seq).toEqual([ + { field: "size", dir: "asc" }, + { field: "size", dir: "desc" }, + { field: "seeders", dir: "asc" }, + { field: "seeders", dir: "desc" }, + { field: "source", dir: "asc" }, + { field: "source", dir: "desc" }, + "none", + ]); + }); + + it("SORT_CYCLE has exactly 7 states starting with none", () => { + expect(SORT_CYCLE).toHaveLength(7); + expect(SORT_CYCLE[0]).toBe("none"); + }); +}); + +describe("sortArrow", () => { + it("points up for asc and down for desc", () => { + expect(sortArrow("asc")).toBe("▴"); + expect(sortArrow("desc")).toBe("▾"); + }); +}); + +describe("sortResults", () => { + it("none preserves the original arrival order", () => { + const list = [ + r({ infoHash: "a", sizeBytes: 1, seeders: 1 }), + r({ infoHash: "b", sizeBytes: 9, seeders: 9 }), + r({ infoHash: "c", sizeBytes: 5, seeders: 5 }), + ]; + expect(ids(sortResults(list, "none"))).toEqual(["a", "b", "c"]); + }); + + it("size asc: smallest first", () => { + const list = [ + r({ infoHash: "a", sizeBytes: 500 }), + r({ infoHash: "b", sizeBytes: 100 }), + r({ infoHash: "c", sizeBytes: 900 }), + ]; + expect(ids(sortResults(list, { field: "size", dir: "asc" }))).toEqual(["b", "a", "c"]); + }); + + it("size desc: largest first", () => { + const list = [ + r({ infoHash: "a", sizeBytes: 500 }), + r({ infoHash: "b", sizeBytes: 100 }), + r({ infoHash: "c", sizeBytes: 900 }), + ]; + expect(ids(sortResults(list, { field: "size", dir: "desc" }))).toEqual(["c", "a", "b"]); + }); + + it("seeders asc: fewest first", () => { + const list = [ + r({ infoHash: "a", seeders: 50 }), + r({ infoHash: "b", seeders: 5 }), + r({ infoHash: "c", seeders: 90 }), + ]; + expect(ids(sortResults(list, { field: "seeders", dir: "asc" }))).toEqual(["b", "a", "c"]); + }); + + it("seeders desc: most first", () => { + const list = [ + r({ infoHash: "a", seeders: 50 }), + r({ infoHash: "b", seeders: 5 }), + r({ infoHash: "c", seeders: 90 }), + ]; + expect(ids(sortResults(list, { field: "seeders", dir: "desc" }))).toEqual(["c", "a", "b"]); + }); + + it("source asc: A->Z by source id", () => { + const list = [ + r({ infoHash: "a", source: "yts" }), + r({ infoHash: "b", source: "eztv" }), + r({ infoHash: "c", source: "nyaa" }), + ]; + expect(ids(sortResults(list, { field: "source", dir: "asc" }))).toEqual(["b", "c", "a"]); + }); + + it("source desc: Z->A by source id", () => { + const list = [ + r({ infoHash: "a", source: "eztv" }), + r({ infoHash: "b", source: "yts" }), + r({ infoHash: "c", source: "nyaa" }), + ]; + expect(ids(sortResults(list, { field: "source", dir: "desc" }))).toEqual(["b", "c", "a"]); + }); + + it("does not mutate the input array", () => { + const list = [ + r({ infoHash: "a", sizeBytes: 1 }), + r({ infoHash: "b", sizeBytes: 2 }), + ]; + const before = ids(list); + sortResults(list, { field: "size", dir: "asc" }); + expect(ids(list)).toEqual(before); + }); +}); diff --git a/src/ui/sort.ts b/src/ui/sort.ts new file mode 100644 index 0000000..0443e85 --- /dev/null +++ b/src/ui/sort.ts @@ -0,0 +1,62 @@ +import type { TorrentResult } from "../sources/types"; + +export type SortField = "size" | "seeders" | "source"; +export type SortDir = "asc" | "desc"; +export interface SortState { + field: SortField; + dir: SortDir; +} + +/** A sort selection, or "none" for the untouched/default order. */ +export type Sort = SortState | "none"; + +/** + * The order the `s` key cycles through: start untouched, then each field + * ascending then descending, then back to untouched. + */ +export const SORT_CYCLE: Sort[] = [ + "none", + { field: "size", dir: "asc" }, + { field: "size", dir: "desc" }, + { field: "seeders", dir: "asc" }, + { field: "seeders", dir: "desc" }, + { field: "source", dir: "asc" }, + { field: "source", dir: "desc" }, +]; + +function sameSort(a: Sort, b: Sort): boolean { + if (a === "none" || b === "none") return a === b; + return a.field === b.field && a.dir === b.dir; +} + +export function nextSort(current: Sort): Sort { + const i = SORT_CYCLE.findIndex((s) => sameSort(s, current)); + return SORT_CYCLE[(i + 1) % SORT_CYCLE.length]!; +} + +export function sortArrow(dir: SortDir): string { + return dir === "asc" ? "▴" : "▾"; +} + +export function sortLabel(sort: Sort): string { + if (sort === "none") return "default"; + return `${sort.field} ${sortArrow(sort.dir)}`; +} + +export function sortResults(list: TorrentResult[], sort: Sort): TorrentResult[] { + const arr = list.slice(); + if (sort === "none") return arr; + const mul = sort.dir === "asc" ? 1 : -1; + switch (sort.field) { + case "size": + arr.sort((a, b) => mul * (a.sizeBytes - b.sizeBytes) || b.seeders - a.seeders); + break; + case "seeders": + arr.sort((a, b) => mul * (a.seeders - b.seeders) || (b.added ?? 0) - (a.added ?? 0)); + break; + case "source": + arr.sort((a, b) => mul * a.source.localeCompare(b.source) || b.seeders - a.seeders); + break; + } + return arr; +}