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 { useConcurrentSearch } from "../hooks/useConcurrentSearch";
|
||||||
import { getSource, SOURCES } from "../../sources/registry";
|
import { getSource, SOURCES } from "../../sources/registry";
|
||||||
import { wrapStep, windowStart } from "../move";
|
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 { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
|
||||||
import { cleanText, formatBytes, formatRelative, truncate } from "../../util/format";
|
import { cleanText, formatBytes, formatRelative, truncate } from "../../util/format";
|
||||||
import type { Source, TorrentResult } from "../../sources/types";
|
import type { Source, TorrentResult } from "../../sources/types";
|
||||||
@@ -123,11 +124,14 @@ export function Results() {
|
|||||||
|
|
||||||
const search = useConcurrentSearch(query);
|
const search = useConcurrentSearch(query);
|
||||||
|
|
||||||
|
const [sort, setSort] = useState<Sort>("none");
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
const cat = CATEGORIES.find((c) => c.key === section);
|
const cat = CATEGORIES.find((c) => c.key === section);
|
||||||
if (!cat?.group) return search.results;
|
const base = cat?.group
|
||||||
return search.results.filter((r) => getSource(r.source).group === cat.group);
|
? search.results.filter((r) => getSource(r.source).group === cat.group)
|
||||||
}, [search.results, section]);
|
: search.results;
|
||||||
|
return sortResults(base, sort);
|
||||||
|
}, [search.results, section, sort]);
|
||||||
|
|
||||||
const focused = region === "content";
|
const focused = region === "content";
|
||||||
const [mode, setMode] = useState<Mode>("list");
|
const [mode, setMode] = useState<Mode>("list");
|
||||||
@@ -194,6 +198,8 @@ export function Results() {
|
|||||||
} else if (input === "y") {
|
} else if (input === "y") {
|
||||||
const r = results[clamped];
|
const r = results[clamped];
|
||||||
if (r) copyResultMagnet(r);
|
if (r) copyResultMagnet(r);
|
||||||
|
} else if (input === "s") {
|
||||||
|
setSort((cur) => nextSort(cur));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: focused && mode === "list" },
|
{ isActive: focused && mode === "list" },
|
||||||
@@ -282,7 +288,18 @@ export function Results() {
|
|||||||
const head = browsing
|
const head = browsing
|
||||||
? "newest across all sources"
|
? "newest across all sources"
|
||||||
: `${results.length} result${results.length === 1 ? "" : "s"}`;
|
: `${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);
|
const start = windowStart(clamped, results.length, listHeight);
|
||||||
@@ -326,10 +343,10 @@ export function Results() {
|
|||||||
{showStats ? (
|
{showStats ? (
|
||||||
<>
|
<>
|
||||||
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||||
<Text bold dimColor>Size</Text>
|
<Text bold dimColor>{sortMark("size", "Size")}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
<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>
|
</Box>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -338,7 +355,7 @@ export function Results() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||||
<Text bold dimColor>Src</Text>
|
<Text bold dimColor>{sortMark("source", "Src")}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -42,13 +42,6 @@ function dedupe(list: TorrentResult[]): TorrentResult[] {
|
|||||||
return [...byHash.values()];
|
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 {
|
function idleState(): ConcurrentSearchState {
|
||||||
return {
|
return {
|
||||||
results: [],
|
results: [],
|
||||||
@@ -105,7 +98,7 @@ export function useConcurrentSearch(query: string): ConcurrentSearchState {
|
|||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
done += 1;
|
done += 1;
|
||||||
setState({
|
setState({
|
||||||
results: sortResults(dedupe(collected.slice())),
|
results: dedupe(collected.slice()),
|
||||||
perSource: { ...per },
|
perSource: { ...per },
|
||||||
loading: done < SOURCES.length,
|
loading: done < SOURCES.length,
|
||||||
done,
|
done,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|||||||
hints: [
|
hints: [
|
||||||
{ keys: "/", label: "Edit search" },
|
{ keys: "/", label: "Edit search" },
|
||||||
{ keys: "↵", label: "Run search" },
|
{ keys: "↵", label: "Run search" },
|
||||||
|
{ keys: "s", label: "Sort results" },
|
||||||
{ keys: "y", label: "Copy magnet" },
|
{ keys: "y", label: "Copy magnet" },
|
||||||
{ keys: "m", label: "Paste magnet" },
|
{ keys: "m", label: "Paste magnet" },
|
||||||
],
|
],
|
||||||
@@ -101,6 +102,7 @@ export function footerHints(
|
|||||||
return [
|
return [
|
||||||
NAVIGATE,
|
NAVIGATE,
|
||||||
{ keys: "d", label: "Download" },
|
{ keys: "d", label: "Download" },
|
||||||
|
{ keys: "s", label: "Sort" },
|
||||||
{ keys: "y", label: "Copy magnet" },
|
{ keys: "y", label: "Copy magnet" },
|
||||||
{ keys: "/", label: "Search" },
|
{ keys: "/", label: "Search" },
|
||||||
{ keys: "m", label: "Paste magnet" },
|
{ 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