mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
Merge branch 'sort-results' into merge/sort-and-folder
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user