feat: make search streaming fluid and the ? cheatsheet responsive

This commit is contained in:
bairon.dev
2026-07-07 14:29:41 -04:00
parent d2126ca8ee
commit 661f90f78d
13 changed files with 266 additions and 91 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ That's the only thing you'll type. torlink opens straight to a search bar: searc
## Finding something
Type what you're looking for and press Enter. Results stream in from every source as they answer, tagged with size and how many people are sharing each one, so you can see what'll come down fast. Arrow to what you want and press `d` to save it, or `D` to pick a different folder for just that download.
Type what you're looking for and press Enter. Results stream in from every source as they answer, tagged with size and how many people are sharing each one, so you can see what'll come down fast. Arrow to what you want and press `d` to save it, or `shift+d` to pick a different folder for just that download.
<p align="center">
<img src="preview/browse.svg" alt="torlink's browse view: the sidebar, the search bar, and merged results from every source" style="max-width: 832px; width: 100%; height: auto;">
@@ -30,7 +30,7 @@ Type what you're looking for and press Enter. Results stream in from every sourc
Active downloads sit up top with their progress, speed, and time left; when one finishes it drops into Recently downloaded just below, so the list stays tidy. Everything's still there when you come back, and anything interrupted picks up where it left off.
Downloads run in the background while you keep searching, so you can queue up as many as you want. They save to your downloads folder press `o` anytime to change where that is, or grab one result with `D` to send it somewhere else without touching the default — and the Downloads pane keeps tabs on each one. When something finishes it keeps seeding automatically so the next person can find it too, and the Seeding tab lets you pause or stop that anytime.
Downloads run in the background while you keep searching, so you can queue up as many as you want. They save to your downloads folder, and the Downloads pane keeps tabs on each one; press `o` anytime to change where that is, or grab one result with `shift+d` to send it somewhere else without touching the default. When something finishes it keeps seeding automatically so the next person can find it too, and the Seeding tab lets you pause or stop that anytime.
<p align="center">
<img src="preview/downloads.svg" alt="torlink's Downloads pane: live progress on top, recently downloaded below" style="max-width: 832px; width: 100%; height: auto;">
+12 -12
View File
@@ -109,17 +109,17 @@
<text x="204.8" y="480" textLength="585.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">╰───────────────────────────────────────────────────────────╯</text>
<text x="41.6" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">↑↓←→</text>
<text x="89.6" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Move</text>
<text x="156.8" y="502" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">d/D</text>
<text x="195.2" y="502" textLength="76.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Download</text>
<text x="300.8" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">y</text>
<text x="320" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Copy</text>
<text x="387.2" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">s</text>
<text x="406.4" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Sort</text>
<text x="473.6" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">/</text>
<text x="492.8" y="502" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Search</text>
<text x="579.2" y="502" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">tab</text>
<text x="617.6" y="502" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Switch</text>
<text x="704" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">?</text>
<text x="723.2" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Keys</text>
<text x="156.8" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">d</text>
<text x="176" y="502" textLength="76.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Download</text>
<text x="281.6" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">y</text>
<text x="300.8" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Copy</text>
<text x="368" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">s</text>
<text x="387.2" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Sort</text>
<text x="454.4" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">/</text>
<text x="473.6" y="502" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Search</text>
<text x="560" y="502" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">tab</text>
<text x="598.4" y="502" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Switch</text>
<text x="684.8" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">?</text>
<text x="704" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Keys</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+8 -4
View File
@@ -104,10 +104,14 @@
<text x="60.8" y="414" textLength="48" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Pause</text>
<text x="137.6" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">c</text>
<text x="156.8" y="414" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Cancel</text>
<text x="243.2" y="414" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">tab</text>
<text x="281.6" y="414" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Switch</text>
<text x="368" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">?</text>
<text x="387.2" y="414" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Keys</text>
<text x="243.2" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">e</text>
<text x="262.4" y="414" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Folder</text>
<text x="348.8" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">s</text>
<text x="368" y="414" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Export</text>
<text x="454.4" y="414" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">tab</text>
<text x="492.8" y="414" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Switch</text>
<text x="579.2" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">?</text>
<text x="598.4" y="414" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Keys</text>
</g>
<rect x="262.4" y="222" width="9.6" height="22" fill="#7c5cd6">
<animate attributeName="fill" calcMode="discrete" dur="3.48s" values="#7c5cd6;#7f5fd7;#8669da;#9277de;#a18ae3;#b29ee8;#c3b3ee;#d2c5f3;#ded4f7;#e5ddfa;#e8e0fb;#e5ddfa;#ded4f7;#d2c5f3;#c3b3ee;#b29ee8;#a18ae3;#9277de;#8669da;#7f5fd7;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6;#7c5cd6" repeatCount="indefinite"/>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+48 -34
View File
@@ -1,63 +1,77 @@
import { Box, Text } from "ink";
import { COL_GAP, FRAME, KEY_W, pickLayout } from "../helpLayout";
import { HELP_GROUPS } from "../keymap";
import { useStore } from "../store";
import { COLOR, RULE, lerpHex } from "../theme";
import { COLOR, ICON, RULE, lerpHex } from "../theme";
const CARD_BORDER = lerpHex(COLOR.accent, RULE, 0.55);
const KEY_GAP = 2;
const COL_GAP = 2;
const KEY_W = HELP_GROUPS.map(
(g) => Math.max(...g.hints.map((h) => h.keys.length)) + KEY_GAP,
);
const COL_W = HELP_GROUPS.map(
(g, i) => KEY_W[i]! + Math.max(...g.hints.map((h) => h.label.length)),
);
const CARD_W =
COL_W.reduce((a, b) => a + b, 0) + (HELP_GROUPS.length - 1) * COL_GAP + 4;
const KEY_W_STACKED = Math.max(...KEY_W);
const FOOT_FULL = "Your downloaded files always stay on disk.";
export function HelpOverlay() {
const { cols } = useStore();
const columns = cols >= CARD_W;
const { cols, rows } = useStore();
const m = pickLayout(cols);
const width = Math.min(m.width, cols - 2);
// Condense when the full card (gridH + 9 rows, under 3 rows of app chrome)
// exceeds the terminal, or the card is too narrow for the two-line footer.
const short = rows < m.gridH + 12 || width - FRAME < FOOT_FULL.length;
return (
<Box
flexDirection="column"
alignSelf="flex-start"
width={width}
borderStyle="round"
borderColor={CARD_BORDER}
paddingX={columns ? 1 : 2}
paddingY={1}
paddingX={1}
paddingY={short ? 0 : 1}
>
<Text bold color={COLOR.accent}>
Keyboard
</Text>
<Box marginTop={1} flexDirection={columns ? "row" : "column"}>
{HELP_GROUPS.map((group, gi) => (
<Box marginTop={1} flexDirection="row">
{m.layout.map((col, ci) => (
<Box
key={group.title}
key={col.join("-")}
flexDirection="column"
width={columns ? COL_W[gi] : undefined}
marginRight={columns && gi < HELP_GROUPS.length - 1 ? COL_GAP : 0}
marginTop={!columns && gi > 0 ? 1 : 0}
width={Math.min(m.colWidths[ci]!, width - FRAME)}
marginRight={ci < m.layout.length - 1 ? COL_GAP : 0}
>
<Text bold>{group.title}</Text>
{group.hints.map((h) => (
<Box key={h.keys + h.label}>
<Box width={columns ? KEY_W[gi] : KEY_W_STACKED} flexShrink={0}>
<Text color={COLOR.alt}>{h.keys}</Text>
{col.map((gi, pos) => {
const group = HELP_GROUPS[gi]!;
return (
<Box
key={group.title}
flexDirection="column"
marginTop={pos > 0 ? 1 : 0}
>
<Text bold>{group.title}</Text>
{group.hints.map((h) => (
<Box key={h.keys + h.label}>
<Box width={KEY_W[gi]} flexShrink={0}>
<Text color={COLOR.alt}>{h.keys}</Text>
</Box>
<Text dimColor wrap="truncate-end">
{h.label}
</Text>
</Box>
))}
</Box>
<Text dimColor>{h.label}</Text>
</Box>
))}
);
})}
</Box>
))}
</Box>
<Box marginTop={1} flexDirection="column">
<Text dimColor>Your downloaded files always stay on disk.</Text>
<Text dimColor>Press ? or esc to close</Text>
</Box>
{short ? (
<Text dimColor wrap="truncate-end">
{`? or esc closes ${ICON.dot} files stay on disk`}
</Text>
) : (
<Box marginTop={1} flexDirection="column">
<Text dimColor>{FOOT_FULL}</Text>
<Text dimColor>Press ? or esc to close</Text>
</Box>
)}
</Box>
);
}
+36 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Box, Text, useInput } from "ink";
import { useStore, CATEGORIES } from "../store";
import { Spinner } from "./Spinner";
@@ -7,7 +7,7 @@ import { Panel } from "./Panel";
import { Rule } from "./Rule";
import { useConcurrentSearch } from "../hooks/useConcurrentSearch";
import { getSource, SOURCES } from "../../sources/registry";
import { wrapStep, windowStart, resultsPanelOuter } from "../move";
import { stickCursor, wrapStep, windowStart, resultsPanelOuter } from "../move";
import { sortResults, nextSort, sortLabel, sortArrow, type Sort, type SortField } from "../sort";
import { filterResults } from "../filter";
import { COLOR, GUTTER, ICON, sourceStyle } from "../theme";
@@ -139,12 +139,20 @@ export function Results() {
const focused = region === "content";
const [mode, setMode] = useState<Mode>("list");
const [cursor, setCursor] = useState(0);
// The row the user navigated to, by infohash; null until they move. Keeps
// the cursor on their row while streamed-in sources reshuffle the list.
const selRef = useRef<string | null>(null);
const [detail, setDetail] = useState<TorrentResult | null>(null);
useEffect(() => {
setCursor(0);
setCursor((c) => stickCursor(results, selRef.current, c));
}, [results]);
useEffect(() => {
selRef.current = null;
setCursor(0);
}, [query, section]);
useEffect(() => {
if (!focused) return;
setCaptureMode(mode === "search" ? "text" : mode === "detail" ? "esc" : "none");
@@ -183,6 +191,11 @@ export function Results() {
const copyResultMagnet = (r: TorrentResult): void =>
copyMagnet({ name: r.name, magnet: r.magnet });
const moveTo = (n: number): void => {
setCursor(n);
selRef.current = results[n]?.infoHash ?? null;
};
useInput(
(input, key) => {
if (input === "/") {
@@ -190,14 +203,14 @@ export function Results() {
return;
}
if (key.upArrow || input === "k") {
if (results.length > 0 && clamped > 0) setCursor(clamped - 1);
if (results.length > 0 && clamped > 0) moveTo(clamped - 1);
else setMode("search");
return;
}
if (results.length === 0) return;
if (key.downArrow || input === "j") 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));
if (key.downArrow || input === "j") moveTo(wrapStep(clamped, 1, results.length));
else if (key.pageUp) moveTo(Math.max(0, clamped - pageJump));
else if (key.pageDown) moveTo(Math.min(results.length - 1, clamped + pageJump));
else if (key.return) {
const r = results[clamped];
if (r) {
@@ -255,6 +268,9 @@ export function Results() {
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);
// Only the active tab's sources hold its spinner; other groups' stragglers
// stream in silently.
const pending = tabSources.some((s) => search.perSource[s.id]?.loading);
const showStats = useMemo(
() => results.some((r) => r.sizeBytes > 0 || r.seeders > 0),
[results],
@@ -270,14 +286,22 @@ export function Results() {
const sortNote = sort === "none" ? "" : ` ${ICON.dot} sort: ${sortLabel(sort)}`;
const filterNote = hideDead ? ` ${ICON.dot} alive only` : "";
const head = browsing
? "newest across all sources"
: `${results.length} result${results.length === 1 ? "" : "s"}`;
const status = () => {
if (search.loading) {
if (pending) {
// Rows are already usable: the settled header simply carries a spinner
// until the tab's last source lands.
if (results.length > 0)
return <Text dimColor>{`searching… ${search.done}/${search.total} sources${sortNote}${filterNote}`}</Text>;
return (
<Spinner label={`${browsing ? "Loading" : "Searching"} ${search.done}/${search.total} sources`} />
);
return (
<Text>
<Text dimColor>{`${head}${sortNote}${filterNote} `}</Text>
<Spinner />
</Text>
);
return <Spinner label={browsing ? "Loading…" : "Searching…"} />;
}
if (results.length === 0) {
if (erroredCount >= search.total) {
@@ -319,9 +343,6 @@ export function Results() {
);
}
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 <Text dimColor>{`${head}${note}${sortNote}${filterNote}`}</Text>;
};
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { MEASURED, pickLayout } from "./helpLayout";
describe("help layout measurement", () => {
it("derives packing widths and grid heights from HELP_GROUPS", () => {
expect(MEASURED.map((m) => m.width)).toEqual([122, 101, 65, 41]);
expect(MEASURED.map((m) => m.gridH)).toEqual([8, 12, 16, 30]);
});
it("picks the widest packing that fits inside cols - 2", () => {
expect(pickLayout(140).layout).toHaveLength(4);
expect(pickLayout(124).layout).toHaveLength(4);
expect(pickLayout(123).layout).toHaveLength(3);
expect(pickLayout(103).layout).toHaveLength(3);
expect(pickLayout(102).layout).toHaveLength(2);
expect(pickLayout(80).layout).toHaveLength(2);
expect(pickLayout(67).layout).toHaveLength(2);
expect(pickLayout(66).layout).toHaveLength(1);
expect(pickLayout(60).layout).toHaveLength(1);
expect(pickLayout(40).layout).toHaveLength(1);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { HELP_GROUPS } from "./keymap";
export const KEY_GAP = 2;
export const COL_GAP = 2;
export const FRAME = 4; // border 2 + paddingX 2
export const KEY_W = HELP_GROUPS.map(
(g) => Math.max(...g.hints.map((h) => h.keys.length)) + KEY_GAP,
);
const COL_W = HELP_GROUPS.map(
(g, i) => KEY_W[i]! + Math.max(...g.hints.map((h) => h.label.length)),
);
const ROWS_PER_GROUP = HELP_GROUPS.map((g) => g.hints.length + 1); // title + hints
// Group-index packings, widest first (which is also shortest first, so the
// width rule below doubles as a height minimizer).
const LAYOUTS: number[][][] = [
[[0], [1], [2], [3]], // 4-col
[[0], [1, 3], [2]], // 3-col, Seeding under Search
[[0, 1], [2, 3]], // 2-col: browse column | transfers column
[[0, 1, 2, 3]], // 1-col
];
export const MEASURED = LAYOUTS.map((layout) => {
const colWidths = layout.map((col) => Math.max(...col.map((gi) => COL_W[gi]!)));
const width =
colWidths.reduce((a, b) => a + b, 0) + (layout.length - 1) * COL_GAP + FRAME;
const gridH = Math.max(
...layout.map(
(col) => col.reduce((a, gi) => a + ROWS_PER_GROUP[gi]!, 0) + (col.length - 1),
),
);
return { layout, colWidths, width, gridH };
});
export function pickLayout(cols: number) {
return MEASURED.find((m) => m.width <= cols - 2) ?? MEASURED.at(-1)!;
}
+4 -15
View File
@@ -11,8 +11,7 @@ export interface SourceState {
count: number;
}
function errorCode(e: unknown, timedOut: boolean): string {
if (timedOut) return "timed out";
function errorCode(e: unknown): string {
if (e instanceof HttpError && e.status > 0) return `HTTP ${e.status}`;
return "no response";
}
@@ -25,8 +24,6 @@ export interface ConcurrentSearchState {
total: number;
}
const PER_SOURCE_TIMEOUT_MS = 25000;
function blankPerSource(loading: boolean): Record<SourceId, SourceState> {
const out = {} as Record<SourceId, SourceState>;
for (const s of SOURCES) out[s.id] = { loading, error: null, code: null, count: 0 };
@@ -80,12 +77,7 @@ export function useConcurrentSearch(query: string): ConcurrentSearchState {
});
for (const source of SOURCES) {
const sc = new AbortController();
const onAbort = (): void => sc.abort();
ctrl.signal.addEventListener("abort", onAbort);
const timer = setTimeout(() => sc.abort(), PER_SOURCE_TIMEOUT_MS);
cachedSearch(source, query, { signal: sc.signal })
cachedSearch(source, query, { signal: ctrl.signal })
.then((res) => {
if (!alive) return;
collected.push(...res);
@@ -93,17 +85,14 @@ export function useConcurrentSearch(query: string): ConcurrentSearchState {
})
.catch((e: unknown) => {
if (!alive || ctrl.signal.aborted) return;
const timedOut = sc.signal.aborted;
per[source.id] = {
loading: false,
error: timedOut ? "timed out" : e instanceof Error ? e.message : String(e),
code: errorCode(e, timedOut),
error: e instanceof Error ? e.message : String(e),
code: errorCode(e),
count: 0,
};
})
.finally(() => {
clearTimeout(timer);
ctrl.signal.removeEventListener("abort", onAbort);
if (!alive) return;
done += 1;
setState({
+3 -3
View File
@@ -14,7 +14,7 @@ export const HELP_GROUPS: HelpGroup[] = [
{
title: "Navigate",
hints: [
{ keys: "↑ ↓ ← →, h j k l", label: "Navigate content and panes" },
{ keys: "↑↓←→ / hjkl", label: "Navigate panes and lists" },
{ keys: "↵", label: "Open" },
{ keys: "tab", label: "Switch pane" },
{ keys: "esc", label: "Back" },
@@ -27,7 +27,7 @@ export const HELP_GROUPS: HelpGroup[] = [
title: "Search",
hints: [
{ keys: "/", label: "Edit search" },
{ keys: "d/D", label: "Download (D picks folder)" },
{ keys: "d", label: "Download (shift+d picks folder)" },
{ keys: "s", label: "Sort results" },
{ keys: "z", label: "Hide dead torrents" },
{ keys: "y", label: "Copy magnet" },
@@ -38,7 +38,7 @@ export const HELP_GROUPS: HelpGroup[] = [
title: "Downloads",
hints: [
{ keys: "p", label: "Pause/resume" },
{ keys: "c", label: "Cancel or remove from list" },
{ keys: "c", label: "Cancel or remove" },
{ keys: "f", label: "Retry failed" },
{ keys: "d", label: "Download again" },
{ keys: "e", label: "Open folder" },
+19 -1
View File
@@ -1,5 +1,23 @@
import { describe, it, expect } from "vitest";
import { wrapStep, windowStart, resultsPanelOuter } from "./move";
import { stickCursor, wrapStep, windowStart, resultsPanelOuter } from "./move";
describe("stickCursor", () => {
const rows = (...hashes: string[]) => hashes.map((infoHash) => ({ infoHash }));
it("pins an untouched cursor to the top while the list reshuffles", () => {
expect(stickCursor(rows("a", "b", "c"), null, 0)).toBe(0);
});
it("follows the selected row to its new index", () => {
expect(stickCursor(rows("b", "c", "a"), "a", 0)).toBe(2);
expect(stickCursor(rows("a", "b"), "b", 1)).toBe(1);
});
it("clamps when the selected row disappears", () => {
expect(stickCursor(rows("a", "b"), "z", 5)).toBe(1);
expect(stickCursor(rows(), "z", 3)).toBe(0);
});
});
describe("wrapStep", () => {
it("wraps around both ends", () => {
+17
View File
@@ -1,3 +1,20 @@
/**
* Where the cursor lands after the list identity changes under it (a source
* streaming in mid-search, a sort cycle, the z filter). Follows the row the
* user selected by infohash; a user who never navigated stays pinned to the
* top so the best result keeps the pointer as arrivals reshuffle the order.
*/
export function stickCursor(
results: readonly { infoHash: string }[],
selected: string | null,
cursor: number,
): number {
if (!selected) return 0;
const idx = results.findIndex((r) => r.infoHash === selected);
if (idx >= 0) return idx;
return Math.min(cursor, Math.max(0, results.length - 1));
}
export function wrapStep(current: number, delta: number, length: number): number {
if (length <= 0) return 0;
return (((current + delta) % length) + length) % length;
+36
View File
@@ -68,4 +68,40 @@ describe("fetchResilient", () => {
expect(res.status).toBe(404);
expect(calls).toBe(1);
});
it("passes the abort signal to the sleep implementation", async () => {
const ctrl = new AbortController();
const seen: (AbortSignal | undefined)[] = [];
await expect(
fetchResilient("http://x", {
retries: 1,
baseMs: 1,
capMs: 1,
signal: ctrl.signal,
sleepImpl: async (_ms, signal) => {
seen.push(signal);
},
fetchImpl: async () => fakeRes(503),
}),
).rejects.toBeInstanceOf(HttpError);
expect(seen).toEqual([ctrl.signal]);
});
it("cuts a backoff sleep short when the signal aborts", async () => {
// No sleepImpl: exercises the real sleep. Retry-After floors the backoff
// at 60s, so only an abort-aware sleep lets this settle quickly.
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 20);
const started = Date.now();
await expect(
fetchResilient("http://x", {
retries: 2,
baseMs: 60_000,
capMs: 60_000,
signal: ctrl.signal,
fetchImpl: async () => fakeRes(503, { "retry-after": "60" }),
}),
).rejects.toBeInstanceOf(HttpError);
expect(Date.now() - started).toBeLessThan(5_000);
});
});
+21 -5
View File
@@ -1,7 +1,7 @@
export const USER_AGENT = "torlink (+https://www.npmjs.com/package/torlnk)";
export type FetchImpl = (url: string, init?: RequestInit) => Promise<Response>;
export type SleepImpl = (ms: number) => Promise<void>;
export type SleepImpl = (ms: number, signal?: AbortSignal) => Promise<void>;
export interface FetchResilientOptions extends RequestInit {
retries?: number;
@@ -26,8 +26,24 @@ const DEFAULT_RETRIES = 5;
const DEFAULT_BASE_MS = 500;
const DEFAULT_CAP_MS = 20000;
function realSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
// Resolves early on abort so a cancelled search never sits out a backoff wait;
// the retry loop re-checks the signal and bails right after.
function realSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const done = (): void => {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
};
const timer = setTimeout(done, ms);
if (signal) {
if (signal.aborted) {
done();
return;
}
signal.addEventListener("abort", done, { once: true });
}
});
}
function isAbortError(e: unknown): boolean {
@@ -89,7 +105,7 @@ export async function fetchResilient(
if (isAbortError(e) || signal?.aborted) throw e;
lastError = e;
if (attempt < retries) {
await sleepImpl(backoffDelay(attempt, baseMs, capMs));
await sleepImpl(backoffDelay(attempt, baseMs, capMs), signal ?? undefined);
continue;
}
throw e;
@@ -113,7 +129,7 @@ export async function fetchResilient(
}
const retryAfterMs = parseRetryAfter(res.headers.get("retry-after"));
await sleepImpl(backoffDelay(attempt, baseMs, capMs, retryAfterMs));
await sleepImpl(backoffDelay(attempt, baseMs, capMs, retryAfterMs), signal ?? undefined);
}
throw lastError instanceof Error