Merge branch 'sort-results' into merge/sort-and-folder

This commit is contained in:
bairon.dev
2026-06-29 23:45:40 -04:00
5 changed files with 214 additions and 15 deletions
+24 -7
View File
@@ -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<Sort>("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<Mode>("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 <Text dimColor>{head + note}</Text>;
const sortNote = sort === "none" ? "" : ` ${ICON.dot} sort: ${sortLabel(sort)}`;
return <Text dimColor>{`${head}${note}${sortNote}`}</Text>;
};
const sortMark = (field: SortField, label: string): ReactNode => {
if (sort === "none" || sort.field !== field) return label;
return (
<>
<Text color={COLOR.accent} bold>{sortArrow(sort.dir)}</Text>
{label}
</>
);
};
const start = windowStart(clamped, results.length, listHeight);
@@ -326,10 +343,10 @@ export function Results() {
{showStats ? (
<>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Size</Text>
<Text bold dimColor>{sortMark("size", "Size")}</Text>
</Box>
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Seed:Lch</Text>
<Text bold dimColor>{sortMark("seeders", "Seed:Lch")}</Text>
</Box>
</>
) : (
@@ -338,7 +355,7 @@ export function Results() {
</Box>
)}
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Src</Text>
<Text bold dimColor>{sortMark("source", "Src")}</Text>
</Box>
</Box>
) : null}
+1 -8
View File
@@ -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,
+2
View File
@@ -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" },
+125
View File
@@ -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<TorrentResult> & { 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);
});
});
+62
View File
@@ -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;
}