From 661f90f78d5d9e4dd6e44e497d95a675cc13d3f3 Mon Sep 17 00:00:00 2001 From: "bairon.dev" Date: Tue, 7 Jul 2026 14:29:10 -0400 Subject: [PATCH] feat: make search streaming fluid and the ? cheatsheet responsive --- README.md | 4 +- preview/browse.svg | 24 ++++----- preview/downloads.svg | 12 +++-- src/ui/components/HelpOverlay.tsx | 82 +++++++++++++++++------------ src/ui/components/Results.tsx | 51 ++++++++++++------ src/ui/helpLayout.test.ts | 22 ++++++++ src/ui/helpLayout.ts | 38 +++++++++++++ src/ui/hooks/useConcurrentSearch.ts | 19 ++----- src/ui/keymap.ts | 6 +-- src/ui/move.test.ts | 20 ++++++- src/ui/move.ts | 17 ++++++ src/util/net.test.ts | 36 +++++++++++++ src/util/net.ts | 26 +++++++-- 13 files changed, 266 insertions(+), 91 deletions(-) create mode 100644 src/ui/helpLayout.test.ts create mode 100644 src/ui/helpLayout.ts diff --git a/README.md b/README.md index eafbfc4..e01bacd 100644 --- a/README.md +++ b/README.md @@ -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.

torlink's browse view: the sidebar, the search bar, and merged results from every source @@ -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.

torlink's Downloads pane: live progress on top, recently downloaded below diff --git a/preview/browse.svg b/preview/browse.svg index 7302068..951a1c8 100644 --- a/preview/browse.svg +++ b/preview/browse.svg @@ -109,17 +109,17 @@ ╰───────────────────────────────────────────────────────────╯ ↑↓←→ Move - d/D - Download - y - Copy - s - Sort - / - Search - tab - Switch - ? - Keys + d + Download + y + Copy + s + Sort + / + Search + tab + Switch + ? + Keys \ No newline at end of file diff --git a/preview/downloads.svg b/preview/downloads.svg index 7c8c339..55d6248 100644 --- a/preview/downloads.svg +++ b/preview/downloads.svg @@ -104,10 +104,14 @@ Pause c Cancel - tab - Switch - ? - Keys + e + Folder + s + Export + tab + Switch + ? + Keys diff --git a/src/ui/components/HelpOverlay.tsx b/src/ui/components/HelpOverlay.tsx index bc210e1..328a56c 100644 --- a/src/ui/components/HelpOverlay.tsx +++ b/src/ui/components/HelpOverlay.tsx @@ -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 ( Keyboard - - {HELP_GROUPS.map((group, gi) => ( + + {m.layout.map((col, ci) => ( 0 ? 1 : 0} + width={Math.min(m.colWidths[ci]!, width - FRAME)} + marginRight={ci < m.layout.length - 1 ? COL_GAP : 0} > - {group.title} - {group.hints.map((h) => ( - - - {h.keys} + {col.map((gi, pos) => { + const group = HELP_GROUPS[gi]!; + return ( + 0 ? 1 : 0} + > + {group.title} + {group.hints.map((h) => ( + + + {h.keys} + + + {h.label} + + + ))} - {h.label} - - ))} + ); + })} ))} - - Your downloaded files always stay on disk. - Press ? or esc to close - + {short ? ( + + {`? or esc closes ${ICON.dot} files stay on disk`} + + ) : ( + + {FOOT_FULL} + Press ? or esc to close + + )} ); } diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index 04bb1c5..fa07e00 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -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("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(null); const [detail, setDetail] = useState(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 {`searching… ${search.done}/${search.total} sources${sortNote}${filterNote}`}; - return ( - - ); + return ( + + {`${head}${sortNote}${filterNote} `} + + + ); + return ; } 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 {`${head}${note}${sortNote}${filterNote}`}; }; diff --git a/src/ui/helpLayout.test.ts b/src/ui/helpLayout.test.ts new file mode 100644 index 0000000..cadc452 --- /dev/null +++ b/src/ui/helpLayout.test.ts @@ -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); + }); +}); diff --git a/src/ui/helpLayout.ts b/src/ui/helpLayout.ts new file mode 100644 index 0000000..eb3d211 --- /dev/null +++ b/src/ui/helpLayout.ts @@ -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)!; +} diff --git a/src/ui/hooks/useConcurrentSearch.ts b/src/ui/hooks/useConcurrentSearch.ts index 9ab931e..a2baa63 100644 --- a/src/ui/hooks/useConcurrentSearch.ts +++ b/src/ui/hooks/useConcurrentSearch.ts @@ -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 { const out = {} as Record; 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({ diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index 4ac325b..72776ba 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -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" }, diff --git a/src/ui/move.test.ts b/src/ui/move.test.ts index c51ad27..3da80d2 100644 --- a/src/ui/move.test.ts +++ b/src/ui/move.test.ts @@ -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", () => { diff --git a/src/ui/move.ts b/src/ui/move.ts index 2aa4f60..b1c8819 100644 --- a/src/ui/move.ts +++ b/src/ui/move.ts @@ -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; diff --git a/src/util/net.test.ts b/src/util/net.test.ts index 4b26601..f9aa28e 100644 --- a/src/util/net.test.ts +++ b/src/util/net.test.ts @@ -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); + }); }); diff --git a/src/util/net.ts b/src/util/net.ts index 77469ed..d60b5a0 100644 --- a/src/util/net.ts +++ b/src/util/net.ts @@ -1,7 +1,7 @@ export const USER_AGENT = "torlink (+https://www.npmjs.com/package/torlnk)"; export type FetchImpl = (url: string, init?: RequestInit) => Promise; -export type SleepImpl = (ms: number) => Promise; +export type SleepImpl = (ms: number, signal?: AbortSignal) => Promise; 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 { - 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 { + 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