feat: terminal-native torrent discovery TUI

Curated, multi-source torrent search and download from the terminal:
concurrent search across built-in sources with streamed, source-tagged
results; a download-only webtorrent engine with true pause/resume and
restart recovery; magnet paste; and a calm Ink + React TUI. No indexer
server, no setup.
This commit is contained in:
bairon.dev
2026-06-27 02:03:19 -04:00
commit 60d7a9a755
71 changed files with 11293 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useApp, useInput, useStdout, useStdin } from "ink";
import { promises as fs } from "node:fs";
import { loadConfig, saveConfig, type Config } from "../config/config";
import { DownloadQueue } from "../download/queue";
import { loadQueue } from "../download/persist";
import { loadHistory } from "../download/history";
import { reconcileQueue } from "../download/reconcile";
import { parseMagnet } from "../sources/magnet";
import { magnetFromTorrentFile } from "../sources/torrentFile";
import { readClipboard } from "../util/clipboard";
import { cleanText, truncate } from "../util/format";
import {
StoreContext,
type CaptureMode,
type DownloadFocus,
type Region,
type Section,
type Store,
type View,
} from "./store";
import { Logo } from "./components/Logo";
import { Sidebar, RAIL_WIDTH } from "./components/Sidebar";
import { Rule } from "./components/Rule";
import { Footer } from "./components/Footer";
import { HelpOverlay } from "./components/HelpOverlay";
import { Results } from "./components/Results";
import { Downloads } from "./components/Downloads";
import { Spinner } from "./components/Spinner";
import { TabTitle } from "./components/TabTitle";
import { Splash } from "./views/Splash";
import { footerHints } from "./keymap";
import { COLOR, ICON } from "./theme";
import { useMouseWheel } from "./hooks/useMouseWheel";
import type { SourceId } from "../sources/types";
export function App({
initialMagnet,
initialTorrent,
onQuit,
}: { initialMagnet?: string; initialTorrent?: string; onQuit?: () => void } = {}) {
useMouseWheel();
const { exit } = useApp();
const { isRawModeSupported } = useStdin();
const { stdout } = useStdout();
const [size, setSize] = useState({
rows: stdout?.rows ?? 24,
cols: stdout?.columns ?? 80,
});
useEffect(() => {
if (!stdout) return;
let last = { rows: stdout.rows ?? 24, cols: stdout.columns ?? 80 };
const onResize = (): void => {
const next = { rows: stdout.rows ?? 24, cols: stdout.columns ?? 80 };
if (next.rows === last.rows && next.cols === last.cols) return;
if (next.rows < last.rows || next.cols < last.cols) {
stdout.write("\x1b[2J\x1b[H");
}
last = next;
setSize(next);
};
stdout.on("resize", onResize);
return () => {
stdout.off("resize", onResize);
};
}, [stdout]);
const rows = size.rows;
const cols = size.cols;
const [queue, setQueue] = useState<DownloadQueue | null>(null);
const [config, setConfigState] = useState<Config | null>(null);
const [view, setView] = useState<View>("splash");
const [query, setQuery] = useState("");
const [section, setSection] = useState<Section>("all");
const [region, setRegion] = useState<Region>("content");
const [captureMode, setCaptureMode] = useState<CaptureMode>("none");
const [downloadFocus, setDownloadFocus] = useState<DownloadFocus | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const booting = useRef(false);
useEffect(() => {
if (booting.current) return;
booting.current = true;
let alive = true;
void (async () => {
const cfg = await loadConfig();
const q = new DownloadQueue();
q.restore(reconcileQueue(await loadQueue()));
q.restoreHistory(await loadHistory());
if (!alive) {
q.suspend();
return;
}
setConfigState(cfg);
setQueue(q);
const launch = initialMagnet
? parseMagnet(initialMagnet)
: initialTorrent
? await magnetFromTorrentFile(initialTorrent)
: null;
if (launch) {
await fs.mkdir(cfg.downloadDir, { recursive: true }).catch(() => {});
q.add(
{ id: launch.infoHash, name: launch.name, magnet: launch.magnet },
cfg.downloadDir,
);
setView("browser");
setSection("downloads");
setRegion("content");
}
})();
return () => {
alive = false;
};
}, [initialMagnet, initialTorrent]);
useEffect(() => {
if (!queue) return;
const onCompleted = (name: string): void =>
setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`);
queue.on("completed", onCompleted);
return () => {
queue.off("completed", onCompleted);
};
}, [queue]);
useEffect(
() => () => {
queue?.suspend();
},
[queue],
);
const quitAll = useCallback(() => {
queue?.suspend();
if (onQuit) onQuit();
else exit();
}, [queue, onQuit, exit]);
const setConfig = useCallback((c: Config) => {
setConfigState(c);
void saveConfig(c);
}, []);
const startDownload = useCallback(
(input: {
id: string;
name: string;
magnet: string;
source?: SourceId;
sizeBytes?: number;
}) => {
if (!config || !queue) return;
void fs.mkdir(config.downloadDir, { recursive: true }).catch(() => {});
queue.add(input, config.downloadDir);
setNotice(`Added: ${truncate(cleanText(input.name), 40)}`);
setSection("downloads");
setRegion("content");
},
[config, queue],
);
const submitQuery = useCallback(
(raw: string) => {
const q = raw.trim();
if (q) {
const magnet = parseMagnet(q);
if (magnet) {
startDownload({
id: magnet.infoHash,
name: magnet.name,
magnet: magnet.magnet,
});
setView("browser");
return;
}
}
setQuery(q);
setView("browser");
if (section === "downloads") setSection("all");
setRegion("content");
},
[section, startDownload],
);
const pasteFromClipboard = useCallback(async () => {
const text = (await readClipboard()).trim();
if (!text) {
setNotice("Clipboard is empty.");
return;
}
const found = text.match(/magnet:\?xt=urn:btih:[^\s"'<>]+/i)?.[0];
const magnet = found ? parseMagnet(found) : null;
if (magnet) {
startDownload({ id: magnet.infoHash, name: magnet.name, magnet: magnet.magnet });
setView("browser");
return;
}
setNotice("No magnet link on the clipboard.");
}, [startDownload]);
useEffect(() => {
if (!notice) return;
const t = setTimeout(() => setNotice(null), 4000);
return () => clearTimeout(t);
}, [notice]);
const compact = rows < 18;
const showTopRule = !compact;
const showFooter = rows >= 12;
const chrome =
3 +
(showTopRule ? 1 : 0) +
(compact ? 0 : 1) +
(showFooter ? 1 : 0);
const bodyH = Math.max(6, rows - 1 - chrome);
const listRows = Math.max(4, bodyH);
const contentWidth = Math.max(24, cols - RAIL_WIDTH - 3);
const ruleWidth = Math.max(10, cols - 2);
const store: Store | null = useMemo(() => {
if (!queue || !config) return null;
return {
config,
setConfig,
queue,
view,
setView,
query,
submitQuery,
section,
setSection,
region: showHelp ? "help" : region,
setRegion,
captureMode,
setCaptureMode,
downloadFocus,
setDownloadFocus,
startDownload,
notice,
setNotice,
quitAll,
listRows,
compact,
contentWidth,
cols,
rows,
};
}, [
queue,
config,
view,
query,
submitQuery,
section,
region,
showHelp,
captureMode,
downloadFocus,
startDownload,
notice,
listRows,
compact,
contentWidth,
cols,
rows,
setConfig,
quitAll,
]);
useInput(
(input, key) => {
if (key.ctrl && input === "c") {
quitAll();
return;
}
if (captureMode === "text") return;
if (showHelp) {
setShowHelp(false);
return;
}
if (input === "?") {
setShowHelp(true);
return;
}
if (input === "m") {
void pasteFromClipboard();
return;
}
if (key.tab) {
setRegion(region === "sidebar" ? "content" : "sidebar");
return;
}
if (key.escape) {
if (captureMode === "esc") return;
if (region === "content") {
setRegion("sidebar");
return;
}
setView("splash");
return;
}
if (input === "q") {
quitAll();
return;
}
},
{ isActive: isRawModeSupported && view === "browser" && !!store },
);
if (!store) {
return (
<Box height={rows} justifyContent="center" alignItems="center">
<Spinner label="Starting torlink" />
</Box>
);
}
if (view === "splash") {
return (
<StoreContext.Provider value={store}>
<TabTitle />
<Splash />
</StoreContext.Provider>
);
}
return (
<StoreContext.Provider value={store}>
<TabTitle />
<Box flexDirection="column" paddingX={1}>
<Box justifyContent="space-between">
<Logo />
{notice ? <Text color={COLOR.good}>{notice}</Text> : null}
</Box>
{showTopRule ? <Rule width={ruleWidth} /> : null}
{showHelp ? (
<Box marginTop={1}>
<HelpOverlay />
</Box>
) : null}
<Box
height={bodyH}
marginTop={compact ? 0 : 1}
display={showHelp ? "none" : "flex"}
overflow="hidden"
>
<Sidebar />
<Box flexGrow={1} flexDirection="column">
{section === "downloads" ? <Downloads /> : <Results />}
</Box>
</Box>
{showFooter ? (
<Box display={showHelp ? "none" : "flex"}>
<Footer hints={footerHints(region, section, downloadFocus)} />
</Box>
) : null}
</Box>
</StoreContext.Provider>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { Box, Text, useInput } from "ink";
import { useStore, useQueueItems, useQueueHistory, type DownloadFocus } from "../store";
import { Panel } from "./Panel";
import { ProgressBar } from "./ProgressBar";
import { wrapStep, windowStart } from "../move";
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
import {
cleanText,
formatBytes,
formatBytesPerSec,
formatEtaShort,
formatRelative,
truncate,
} from "../../util/format";
import type { QueueItem } from "../../download/types";
import type { HistoryItem } from "../../download/history";
const ROWS_PER_ACTIVE = 2;
const MARK = 2;
const PAUSED = "#7c7785";
function statusColor(status: QueueItem["status"]): string {
if (status === "failed") return COLOR.bad;
if (status === "paused") return PAUSED;
return COLOR.accent;
}
function statusIcon(status: QueueItem["status"]): string {
if (status === "failed") return ICON.error;
if (status === "paused") return ICON.pause;
return ICON.down;
}
function rightStats(it: QueueItem): string {
if (it.status === "downloading") {
const speed = formatBytesPerSec(it.speed) || "…";
const eta = it.eta ? ` ${formatEtaShort(it.eta)}` : "";
return `${it.progress}% ${speed} ${ICON.peer}${it.peers}${eta}`;
}
if (it.status === "paused") return `paused ${it.progress}%`;
return truncate(it.error || "failed", 28);
}
export function Downloads() {
const { queue, region, contentWidth, listRows, startDownload, setDownloadFocus } = useStore();
const active = useQueueItems(queue);
const recent = useQueueHistory(queue);
const focused = region === "content";
const total = active.length + recent.length;
const [cursor, setCursor] = useState(0);
const clamped = Math.min(cursor, Math.max(0, total - 1));
const inActive = clamped < active.length;
const recentCursor = clamped - active.length;
useInput(
(input, key) => {
if (key.upArrow) setCursor(wrapStep(clamped, -1, total));
else if (key.downArrow) setCursor(wrapStep(clamped, 1, total));
else if (input === "f") queue.retryFailed();
else if (input === "x") queue.clearHistory();
else if (inActive) {
const it = active[clamped];
if (!it) return;
if (input === "c") queue.cancel(it.id);
else if (input === "p") queue.togglePause(it.id);
} else {
const h = recent[recentCursor];
if (!h) return;
if (key.return || input === "d")
startDownload({
id: h.id,
name: h.name,
magnet: h.magnet,
source: h.source,
sizeBytes: h.sizeBytes,
});
else if (input === "c") queue.removeHistory(h.id);
}
},
{ isActive: focused && total > 0 },
);
let focusKind: DownloadFocus | null = null;
if (focused && total > 0) {
if (!inActive) focusKind = "recent";
else {
const st = active[clamped]?.status;
if (st === "downloading" || st === "paused" || st === "failed") focusKind = st;
}
}
useEffect(() => {
setDownloadFocus(focusKind);
return () => setDownloadFocus(null);
}, [focusKind, setDownloadFocus]);
const panelH = Math.max(5, listRows - 1);
if (total === 0) {
return (
<Panel title="downloads" width={contentWidth} focused={focused} height={panelH}>
<Text dimColor>No downloads yet. Find something and press d to grab it.</Text>
</Panel>
);
}
const hasActive = active.length > 0;
const hasRecent = recent.length > 0;
const headerRows = hasRecent ? 1 : 0;
const ceiling = Math.max(1, panelH - 1);
let gapRows = hasActive && hasRecent ? 1 : 0;
let maxActive = 0;
let maxRecent = 0;
if (!hasRecent) {
maxActive = Math.max(1, Math.floor(ceiling / ROWS_PER_ACTIVE));
} else if (!hasActive) {
maxRecent = Math.max(1, ceiling - headerRows);
} else {
let budget = ceiling - headerRows - gapRows;
if (budget < ROWS_PER_ACTIVE + 1) {
gapRows = 0;
budget = ceiling - headerRows;
}
const activeRowCap = Math.max(ROWS_PER_ACTIVE, Math.floor(budget * 0.55));
maxActive = Math.min(active.length, Math.max(1, Math.floor(activeRowCap / ROWS_PER_ACTIVE)));
maxRecent = Math.max(1, budget - maxActive * ROWS_PER_ACTIVE);
}
const activeStart = windowStart(inActive ? clamped : 0, active.length, maxActive);
const activeVisible = active.slice(activeStart, activeStart + maxActive);
const recentStart = windowStart(inActive ? 0 : recentCursor, recent.length, maxRecent);
const recentVisible = recent.slice(recentStart, recentStart + maxRecent);
const inner = contentWidth - 4;
const gap = 2;
const barW = Math.max(8, Math.min(28, Math.floor(inner * 0.4)));
const statsW = Math.max(6, inner - MARK - GUTTER - barW - gap);
const count = hasActive ? `(${active.length})` : undefined;
return (
<Panel title="downloads" width={contentWidth} focused={focused} count={count} height={panelH}>
{activeVisible.map((it, i) => {
const here = activeStart + i === clamped && focused && inActive;
const sc = statusColor(it.status);
const ss = SOURCE_STYLE[it.source ?? "fitgirl"];
return (
<Box key={it.id} flexDirection="column">
<Box>
<Box width={MARK} flexShrink={0}>
<Text color={COLOR.accent} bold>
{here ? ICON.pointer : ""}
</Text>
</Box>
<Box width={GUTTER} flexShrink={0}>
<Text color={sc}>{statusIcon(it.status)}</Text>
</Box>
<Box flexGrow={1} minWidth={0}>
<Text
wrap="truncate-end"
bold={here}
color={here ? COLOR.accent : undefined}
dimColor={!here}
>
{cleanText(it.name)}
</Text>
</Box>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{it.totalBytes > 0 ? formatBytes(it.totalBytes) : "-"}</Text>
</Box>
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={it.source ? ss.color : undefined} dimColor={!it.source || !here}>
{it.source ? ss.tag : "mag"}
</Text>
</Box>
</Box>
<Box>
<Box width={MARK + GUTTER} flexShrink={0} />
<ProgressBar
pct={it.progress}
width={barW}
color={sc}
animate={it.status === "downloading"}
/>
<Box marginLeft={gap} flexShrink={0}>
<Text dimColor>{truncate(rightStats(it), statsW)}</Text>
</Box>
</Box>
</Box>
);
})}
{hasRecent ? (
<Box marginTop={gapRows ? 1 : 0}>
<Text dimColor>{`Recently downloaded${recent.length > 1 ? ` (${recent.length})` : ""}`}</Text>
</Box>
) : null}
{recentVisible.map((h: HistoryItem, i) => {
const here = recentStart + i === recentCursor && focused && !inActive;
const ss = SOURCE_STYLE[h.source ?? "fitgirl"];
const when = formatRelative(h.completedAt / 1000);
return (
<Box key={h.id}>
<Box width={MARK} flexShrink={0}>
<Text color={COLOR.accent} bold>
{here ? ICON.pointer : ""}
</Text>
</Box>
<Box width={GUTTER} flexShrink={0}>
<Text color={COLOR.good} dimColor={!here}>
{ICON.done}
</Text>
</Box>
<Box flexGrow={1} minWidth={0}>
<Text
wrap="truncate-end"
bold={here}
color={here ? COLOR.accent : undefined}
dimColor={!here}
>
{cleanText(h.name)}
</Text>
</Box>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"}</Text>
</Box>
<Box width={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{when || "-"}</Text>
</Box>
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={h.source ? ss.color : undefined} dimColor={!h.source || !here}>
{h.source ? ss.tag : "mag"}
</Text>
</Box>
</Box>
);
})}
</Panel>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Box, Text } from "ink";
import { COLOR } from "../theme";
import type { Hint } from "../keymap";
export function Footer({ hints }: { hints: Hint[] }) {
return (
<Box>
<Text>
{hints.map((h, i) => (
<Text key={h.keys + h.label}>
{i > 0 ? <Text dimColor>{" "}</Text> : null}
<Text color={COLOR.alt}>{h.keys}</Text>
<Text dimColor>{` ${h.label}`}</Text>
</Text>
))}
</Text>
</Box>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { Box, Text } from "ink";
import { HELP_GROUPS } from "../keymap";
import { useStore } from "../store";
import { COLOR, 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);
export function HelpOverlay() {
const { cols } = useStore();
const columns = cols >= CARD_W;
return (
<Box
flexDirection="column"
alignSelf="flex-start"
borderStyle="round"
borderColor={CARD_BORDER}
paddingX={columns ? 1 : 2}
paddingY={1}
>
<Text bold color={COLOR.accent}>
Keyboard
</Text>
<Box marginTop={1} flexDirection={columns ? "row" : "column"}>
{HELP_GROUPS.map((group, gi) => (
<Box
key={group.title}
flexDirection="column"
width={columns ? COL_W[gi] : undefined}
marginRight={columns && gi < HELP_GROUPS.length - 1 ? COL_GAP : 0}
marginTop={!columns && gi > 0 ? 1 : 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>
</Box>
<Text dimColor>{h.label}</Text>
</Box>
))}
</Box>
))}
</Box>
<Box marginTop={1}>
<Text dimColor>Press ? or esc to close</Text>
</Box>
</Box>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { Box, Text } from "ink";
import { LOGO_LINES, SPROUT_CELLS } from "../logo";
import { COLOR, lerpHex } from "../theme";
const HIGHLIGHT = "#ffffff";
const TOP = COLOR.bright;
const BASE = "#7c5cd6";
const SHADE = "#4c3a8a";
const SPROUT_COLOR = "#5ae87a";
function getSheen(t: number): string {
if (t < 0.15) return lerpHex(HIGHLIGHT, TOP, t / 0.15);
if (t < 0.4) return lerpHex(TOP, COLOR.accent, (t - 0.15) / 0.25);
if (t < 0.7) return lerpHex(COLOR.accent, BASE, (t - 0.4) / 0.3);
return lerpHex(BASE, SHADE, (t - 0.7) / 0.3);
}
export function Logo() {
const rows = LOGO_LINES.length;
return (
<Box flexDirection="column">
{LOGO_LINES.map((line, row) => {
const textRow = Math.max(0, row - 1);
const textRows = Math.max(1, rows - 1);
const tY = textRow / (textRows - 1 || 1);
const chars = [...line];
const last = Math.max(1, chars.length - 1);
return (
<Box key={row}>
{chars.map((ch, i) => {
if (ch === " ") return <Text key={i}> </Text>;
if (SPROUT_CELLS.has(`${row},${i}`)) {
return (
<Text key={i} bold color={SPROUT_COLOR}>
{ch}
</Text>
);
}
const tX = i / last;
const factor = (tX + tY) / 2;
return (
<Text key={i} bold color={getSheen(factor)}>
{ch}
</Text>
);
})}
</Box>
);
})}
</Box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
import { Box, Text } from "ink";
import { COLOR, RULE } from "../theme";
interface PanelProps {
title: string;
width: number;
focused?: boolean;
count?: string;
height?: number;
children: ReactNode;
}
export function Panel({ title, width, focused, count, height, children }: PanelProps) {
const color = focused ? COLOR.accent : RULE;
const w = Math.max(10, width);
const cap = title.charAt(0).toUpperCase() + title.slice(1);
const label = count ? `${cap} ${count}` : cap;
const fill = Math.max(0, w - 5 - label.length);
return (
<Box flexDirection="column" width={w}>
<Box>
<Text color={color}>{"╭─ "}</Text>
<Text bold color={color}>
{label}
</Text>
<Text color={color}>{` ${"─".repeat(fill)}`}</Text>
</Box>
<Box
width={w}
height={height}
flexGrow={height ? 0 : 1}
flexDirection="column"
borderStyle="round"
borderTop={false}
borderColor={color}
paddingX={1}
overflow="hidden"
>
{children}
</Box>
</Box>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Text } from "ink";
import { COLOR, RULE, lerpHex } from "../theme";
const DEEP = "#7c5cd6";
const SHEEN_PEAK = "#f4efff";
const RADIUS = 3.5;
const GAP = 8;
const MAX_SHEEN = 0.9;
const TICK_MS = 90;
interface Run {
color: string;
len: number;
}
function ramp(t: number, deep: string, mid: string, bright: string): string {
return t <= 0.5 ? lerpHex(deep, mid, t / 0.5) : lerpHex(mid, bright, (t - 0.5) / 0.5);
}
function runs(colors: string[]): Run[] {
const out: Run[] = [];
for (const c of colors) {
const prev = out[out.length - 1];
if (prev && prev.color === c) prev.len++;
else out.push({ color: c, len: 1 });
}
return out;
}
function paint(rs: Run[]) {
return rs.map((r, i) => (
<Text key={i} color={r.color}>
{"█".repeat(r.len)}
</Text>
));
}
export function ProgressBar({
pct,
width,
color = COLOR.accent,
animate = false,
}: {
pct: number;
width: number;
color?: string;
animate?: boolean;
}) {
const clamped = Math.max(0, Math.min(100, pct));
const filled = Math.round((clamped / 100) * width);
const empty = Math.max(0, width - filled);
const denom = Math.max(1, width - 1);
const [tick, setTick] = useState(0);
useEffect(() => {
if (!animate) return;
const timer = setInterval(() => setTick((v) => v + 1), TICK_MS);
timer.unref?.();
return () => clearInterval(timer);
}, [animate]);
const track = empty > 0 ? <Text color={RULE}>{"░".repeat(empty)}</Text> : null;
if (filled === 0) return <Text>{track}</Text>;
if (!animate) {
const deep = lerpHex(color, "#000000", 0.3);
const bright = lerpHex(color, COLOR.text, 0.35);
const cells = Array.from({ length: filled }, (_, i) => ramp(i / denom, deep, color, bright));
return (
<Text>
{paint(runs(cells))}
{track}
</Text>
);
}
const period = Math.ceil(width + RADIUS * 2) + GAP;
const center = (tick % period) - RADIUS;
const cells = Array.from({ length: filled }, (_, i) => {
let c = ramp(i / denom, DEEP, COLOR.accent, COLOR.bright);
const d = Math.abs(i - center);
if (d < RADIUS) {
const intensity = 0.5 * (1 + Math.cos((Math.PI * d) / RADIUS)) * MAX_SHEEN;
c = lerpHex(c, SHEEN_PEAK, intensity);
}
return c;
});
return (
<Text>
{paint(runs(cells))}
{track}
</Text>
);
}
+370
View File
@@ -0,0 +1,370 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Box, Text, useInput } from "ink";
import { useStore, CATEGORIES } from "../store";
import { Spinner } from "./Spinner";
import { SearchBar } from "./SearchBar";
import { Panel } from "./Panel";
import { Rule } from "./Rule";
import { useConcurrentSearch } from "../hooks/useConcurrentSearch";
import { getSource, SOURCES } from "../../sources/registry";
import { wrapStep, windowStart } from "../move";
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
import { cleanText, formatBytes, formatRelative, truncate } from "../../util/format";
import type { Source, TorrentResult } from "../../sources/types";
type Mode = "list" | "search" | "detail";
const PLACEHOLDER = "Search, or paste a magnet…";
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<Box>
<Box width={9} flexShrink={0}>
<Text dimColor>{label}</Text>
</Box>
<Box flexGrow={1} minWidth={0}>{value}</Box>
</Box>
);
}
function Detail({ r, width }: { r: TorrentResult; width: number }) {
const ss = SOURCE_STYLE[r.source];
const date = formatRelative(r.added);
const health =
r.seeders || r.leechers ? (
<Text>
<Text color={r.seeders > 0 ? COLOR.good : undefined} bold={r.seeders > 0}>
{r.seeders}
</Text>
<Text dimColor>{` seeders ${ICON.dot} ${r.leechers} leechers`}</Text>
</Text>
) : (
<Text dimColor>unknown</Text>
);
return (
<Box flexDirection="column">
<Box>
<Box flexGrow={1} minWidth={0}>
<Text bold color={COLOR.text} wrap="truncate-end">
{cleanText(r.name)}
</Text>
</Box>
<Box flexShrink={0} marginLeft={2}>
<Text color={ss.color} bold>
{ss.tag}
</Text>
</Box>
</Box>
<Rule width={width} />
<Box marginTop={1} flexDirection="column">
<DetailRow
label="Size"
value={
r.sizeBytes > 0 ? (
<Text color={COLOR.text}>{formatBytes(r.sizeBytes)}</Text>
) : (
<Text dimColor>unknown</Text>
)
}
/>
<DetailRow label="Health" value={health} />
{r.numFiles ? (
<DetailRow label="Files" value={<Text dimColor>{String(r.numFiles)}</Text>} />
) : null}
{date ? <DetailRow label="Added" value={<Text dimColor>{date}</Text>} /> : null}
<DetailRow
label="Hash"
value={
<Text color={COLOR.alt} dimColor wrap="truncate-end">
{r.infoHash}
</Text>
}
/>
</Box>
<Box marginTop={1}>
<Text color={COLOR.accent} bold>
d
</Text>
<Text color={COLOR.text}> Download</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> back</Text>
</Box>
</Box>
);
}
export function Results() {
const {
query,
submitQuery,
section,
region,
setCaptureMode,
startDownload,
contentWidth,
listRows,
} = useStore();
const search = useConcurrentSearch(query);
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 focused = region === "content";
const [mode, setMode] = useState<Mode>("list");
const [cursor, setCursor] = useState(0);
const [detail, setDetail] = useState<TorrentResult | null>(null);
useEffect(() => {
setCursor(0);
}, [results]);
useEffect(() => {
if (!focused) return;
setCaptureMode(mode === "search" ? "text" : mode === "detail" ? "esc" : "none");
return () => setCaptureMode("none");
}, [mode, focused, setCaptureMode]);
useEffect(() => {
if (!focused) setMode("list");
}, [focused]);
const clamped = Math.min(cursor, Math.max(0, results.length - 1));
const searchH = 3;
const panelOuter = Math.max(5, listRows - searchH - 1);
const listHeight = Math.max(3, panelOuter - 4);
const pageJump = Math.max(1, listHeight - 1);
const openDownload = (r: TorrentResult): void =>
startDownload({
id: r.infoHash,
name: r.name,
magnet: r.magnet,
source: r.source,
sizeBytes: r.sizeBytes,
});
useInput(
(input, key) => {
if (input === "/") {
setMode("search");
return;
}
if (results.length === 0) return;
if (key.upArrow) setCursor(wrapStep(clamped, -1, results.length));
else if (key.downArrow) 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));
else if (key.return) {
const r = results[clamped];
if (r) {
setDetail(r);
setMode("detail");
}
} else if (input === "d") {
const r = results[clamped];
if (r) openDownload(r);
}
},
{ isActive: focused && mode === "list" },
);
useInput(
(input, key) => {
if (key.escape) {
setMode("list");
setDetail(null);
} else if (input === "d" && detail) openDownload(detail);
},
{ isActive: focused && mode === "detail" },
);
useInput(
(_input, key) => {
if (key.escape) setMode("list");
},
{ isActive: focused && mode === "search" },
);
const onSubmit = (value: string): void => {
setMode("list");
submitQuery(value);
};
const browsing = query.trim() === "";
const erroredCount = useMemo(
() => Object.values(search.perSource).filter((s) => s.error).length,
[search.perSource],
);
const activeCat = CATEGORIES.find((c) => c.key === section);
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);
const showStats = useMemo(
() => results.some((r) => r.sizeBytes > 0 || r.seeders > 0),
[results],
);
const numW = Math.max(2, String(results.length).length);
const outageCodes = (sources: readonly Source[]): string => {
const codes = [
...new Set(sources.map((s) => search.perSource[s.id]?.code).filter(Boolean)),
];
return codes.length ? ` (${codes.join(", ")})` : "";
};
const status = () => {
if (search.loading) {
if (results.length > 0)
return <Text dimColor>{`searching… ${search.done}/${search.total} sources`}</Text>;
return (
<Spinner label={`${browsing ? "Loading" : "Searching"} ${search.done}/${search.total} sources`} />
);
}
if (results.length === 0) {
if (erroredCount >= search.total) {
const downAll = SOURCES.filter((s) => search.perSource[s.id]?.error);
return (
<Text color={COLOR.warn}>
{`Couldn't reach any source. They may be down${outageCodes(downAll)}.`}
</Text>
);
}
if (tabErrored && activeCat) {
const down = tabSources.filter((s) => search.perSource[s.id]?.error);
const who = down.length === 1 ? "The source" : `All ${down.length} sources`;
return (
<Text color={COLOR.warn}>
{`Couldn't reach ${activeCat.label}. ${who} may be down${outageCodes(down)}.`}
</Text>
);
}
if (search.results.length > 0 && activeCat?.group)
return <Text dimColor>{`No ${activeCat.label.toLowerCase()} results yet. Try another tab or a search.`}</Text>;
return (
<Text dimColor>
{browsing ? "Nothing new right now." : `No results for "${truncate(query, 28)}".`}
</Text>
);
}
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}</Text>;
};
const start = windowStart(clamped, results.length, listHeight);
const visible = results.slice(start, start + listHeight);
const count = results.length > 0 ? `(${results.length})` : undefined;
return (
<Box flexDirection="column">
<SearchBar
width={contentWidth}
value={query}
editing={mode === "search"}
placeholder={PLACEHOLDER}
onSubmit={onSubmit}
/>
<Box marginTop={1}>
<Panel
title={mode === "detail" ? "details" : browsing ? "latest" : "results"}
width={contentWidth}
focused={focused}
count={mode === "detail" ? undefined : count}
height={panelOuter}
>
{mode === "detail" && detail ? (
<Detail r={detail} width={Math.max(10, contentWidth - 4)} />
) : (
<>
<Box>{status()}</Box>
<Box flexDirection="column" marginTop={results.length > 0 ? 1 : 0}>
{results.length > 0 ? (
<Box>
<Box width={GUTTER} flexShrink={0} />
<Box width={numW} flexShrink={0} justifyContent="flex-end">
<Text bold dimColor>#</Text>
</Box>
<Box flexGrow={1} minWidth={0} marginLeft={1}>
<Text bold dimColor>Name</Text>
</Box>
{showStats ? (
<>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Size</Text>
</Box>
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Seed:Lch</Text>
</Box>
</>
) : (
<Box width={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Added</Text>
</Box>
)}
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Src</Text>
</Box>
</Box>
) : null}
{visible.map((r, i) => {
const index = start + i;
const here = index === clamped && focused && mode === "list";
const ss = SOURCE_STYLE[r.source];
return (
<Box key={r.infoHash}>
<Box width={GUTTER} flexShrink={0}>
<Text color={COLOR.accent}>{here ? ICON.pointer : ""}</Text>
</Box>
<Box width={numW} flexShrink={0} justifyContent="flex-end">
<Text dimColor>{index + 1}</Text>
</Box>
<Box flexGrow={1} minWidth={0} marginLeft={1}>
<Text
wrap="truncate-end"
color={here ? COLOR.accent : undefined}
dimColor={!here}
bold={here}
>
{cleanText(r.name)}
</Text>
</Box>
{showStats ? (
<>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"}</Text>
</Box>
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={r.seeders > 0 ? COLOR.good : undefined} dimColor={r.seeders === 0}>
{r.seeders || r.leechers ? `${r.seeders}:${r.leechers}` : "-"}
</Text>
</Box>
</>
) : (
<Box width={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{formatRelative(r.added) || "-"}</Text>
</Box>
)}
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={ss.color} dimColor={!here}>
{ss.tag}
</Text>
</Box>
</Box>
);
})}
</Box>
</>
)}
</Panel>
</Box>
</Box>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { Text } from "ink";
import { RULE } from "../theme";
export function Rule({ width }: { width: number }) {
return <Text color={RULE}>{"─".repeat(Math.max(1, width))}</Text>;
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, Text } from "ink";
import { TextField } from "./TextField";
import { Panel } from "./Panel";
import { COLOR, ICON } from "../theme";
interface SearchBarProps {
width: number;
value: string;
placeholder?: string;
editing: boolean;
onSubmit: (value: string) => void;
onChange?: (value: string) => void;
}
export function SearchBar({
width,
value,
placeholder = "Search torrents…",
editing,
onSubmit,
onChange,
}: SearchBarProps) {
return (
<Panel title="search" width={width} focused={editing} height={2}>
<Box>
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
<Box flexGrow={1} minWidth={0}>
{editing ? (
<TextField
defaultValue={value}
placeholder={placeholder}
onSubmit={onSubmit}
onChange={onChange}
/>
) : value ? (
<Text wrap="truncate-end">{value}</Text>
) : (
<Text dimColor>{placeholder}</Text>
)}
</Box>
</Box>
</Panel>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { Box, Text, useInput } from "ink";
import { useStore, useQueueItems, CATEGORIES, type Section } from "../store";
import { wrapStep } from "../move";
import { ACCENT_RAMP, COLOR, GUTTER, ICON, RULE } from "../theme";
interface NavItem {
key: Section;
label: string;
}
const FILTERS: NavItem[] = CATEGORIES.map((c) => ({
key: c.key as Section,
label: c.label,
}));
const LIBRARY: NavItem[] = [{ key: "downloads", label: "Downloads" }];
const GROUPS: NavItem[][] = [FILTERS, LIBRARY];
const NAV: NavItem[] = GROUPS.flat();
const BADGE_W = " (00)".length;
export const RAIL_WIDTH =
GUTTER +
Math.max(...NAV.map((n) => n.label.length + (n.key === "downloads" ? BADGE_W : 0)));
export function Sidebar() {
const { section, setSection, region, setRegion, queue } = useStore();
const focused = region === "sidebar";
const idx = Math.max(0, NAV.findIndex((n) => n.key === section));
useQueueItems(queue);
const active = queue.activeCount;
useInput(
(_input, key) => {
if (key.upArrow) setSection(NAV[wrapStep(idx, -1, NAV.length)]!.key);
else if (key.downArrow) setSection(NAV[wrapStep(idx, 1, NAV.length)]!.key);
else if (key.return) setRegion("content");
},
{ isActive: focused },
);
return (
<Box flexDirection="column" width={RAIL_WIDTH} marginRight={1}>
{GROUPS.map((items, gi) => (
<Box key={gi} flexDirection="column" marginTop={gi > 0 ? 1 : 0}>
{items.map((item) => {
const selected = item.key === section;
return (
<Box key={item.key}>
<Box width={GUTTER} flexShrink={0}>
{selected ? (
<Text color={focused ? ACCENT_RAMP[1] : RULE} bold={focused}>
{ICON.bar}
</Text>
) : null}
</Box>
<Text
color={selected ? (focused ? COLOR.accent : COLOR.alt) : undefined}
dimColor={!selected}
bold={selected && focused}
>
{item.label}
</Text>
{item.key === "downloads" && active > 0 ? (
<Box flexShrink={0}>
<Text dimColor>{` (${active})`}</Text>
</Box>
) : null}
</Box>
);
})}
</Box>
))}
</Box>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { Text } from "ink";
import { COLOR } from "../theme";
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export function Spinner({ label }: { label?: string }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
timer.unref?.();
return () => clearInterval(timer);
}, []);
return (
<Text>
<Text color={COLOR.accent}>{FRAMES[frame]}</Text>
{label ? <Text dimColor>{` ${label}`}</Text> : null}
</Text>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useStore, useQueueItems } from "../store";
export function TabTitle() {
const { queue } = useStore();
useQueueItems(queue);
const active = queue.activeCount;
useEffect(() => {
const title = active > 0 ? `${active} · torlink` : "torlink";
process.stdout.write(`\x1b]0;${title}\x07`);
if (process.platform === "win32") process.title = title;
}, [active]);
return null;
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from "react";
import { Text, useInput } from "ink";
export interface TextFieldProps {
isDisabled?: boolean;
defaultValue?: string;
placeholder?: string;
onChange?: (value: string) => void;
onSubmit?: (value: string) => void;
}
interface Edit {
value: string;
cursor: number;
}
export function deleteBefore(value: string, cursor: number): Edit {
if (cursor === 0) return { value, cursor };
return {
value: value.slice(0, cursor - 1) + value.slice(cursor),
cursor: cursor - 1,
};
}
export function deleteWordBefore(value: string, cursor: number): Edit {
let i = cursor;
while (i > 0 && value[i - 1] === " ") i--;
while (i > 0 && value[i - 1] !== " ") i--;
return { value: value.slice(0, i) + value.slice(cursor), cursor: i };
}
export function killToEnd(value: string, cursor: number): Edit {
return { value: value.slice(0, cursor), cursor };
}
export function insertAt(value: string, cursor: number, text: string): Edit {
return {
value: value.slice(0, cursor) + text + value.slice(cursor),
cursor: cursor + text.length,
};
}
const CURSOR = " ";
export function TextField({
isDisabled = false,
defaultValue = "",
placeholder = "",
onChange,
onSubmit,
}: TextFieldProps) {
const [value, setValue] = useState(defaultValue);
const [cursor, setCursor] = useState(defaultValue.length);
function apply(next: Edit): void {
setValue(next.value);
setCursor(Math.max(0, Math.min(next.value.length, next.cursor)));
if (next.value !== value) onChange?.(next.value);
}
useInput(
(input, key) => {
if (key.upArrow || key.downArrow || key.tab || (key.ctrl && input === "c"))
return;
if (key.return) {
onSubmit?.(value);
return;
}
if (key.ctrl) {
switch (input) {
case "u":
apply({ value: "", cursor: 0 });
return;
case "w":
apply(deleteWordBefore(value, cursor));
return;
case "k":
apply(killToEnd(value, cursor));
return;
case "a":
setCursor(0);
return;
case "e":
setCursor(value.length);
return;
default:
return;
}
}
if (key.leftArrow) {
setCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.backspace || key.delete) {
apply(deleteBefore(value, cursor));
return;
}
if (key.meta || !input) return;
const text = input.replace(/\x1b?\[<\d+;\d+;\d+[Mm]/g, "");
if (!text) return;
apply(insertAt(value, cursor, text));
},
{ isActive: !isDisabled },
);
if (isDisabled) {
return value ? <Text>{value}</Text> : <Text dimColor>{placeholder}</Text>;
}
if (value.length === 0) {
if (placeholder) {
return (
<Text>
<Text inverse>{placeholder[0]}</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</Text>
);
}
return <Text inverse>{CURSOR}</Text>;
}
const before = value.slice(0, cursor);
const atChar = value[cursor] ?? CURSOR;
const after = cursor < value.length ? value.slice(cursor + 1) : "";
return (
<Text>
{before}
<Text inverse>{atChar}</Text>
{after}
</Text>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import { SOURCES } from "../../sources/registry";
import { cachedSearch } from "../../sources/cache";
import { HttpError } from "../../util/net";
import type { SourceId, TorrentResult } from "../../sources/types";
export interface SourceState {
loading: boolean;
error: string | null;
code: string | null;
count: number;
}
function errorCode(e: unknown, timedOut: boolean): string {
if (timedOut) return "timed out";
if (e instanceof HttpError && e.status > 0) return `HTTP ${e.status}`;
return "no response";
}
export interface ConcurrentSearchState {
results: TorrentResult[];
perSource: Record<SourceId, SourceState>;
loading: boolean;
done: number;
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 };
return out;
}
function dedupe(list: TorrentResult[]): TorrentResult[] {
const byHash = new Map<string, TorrentResult>();
for (const r of list) {
const existing = byHash.get(r.infoHash);
if (!existing || r.seeders > existing.seeders) byHash.set(r.infoHash, r);
}
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: [],
perSource: blankPerSource(false),
loading: false,
done: 0,
total: SOURCES.length,
};
}
export function useConcurrentSearch(query: string): ConcurrentSearchState {
const [state, setState] = useState<ConcurrentSearchState>(idleState);
useEffect(() => {
const ctrl = new AbortController();
let alive = true;
const collected: TorrentResult[] = [];
const per = blankPerSource(true);
let done = 0;
setState({
results: [],
perSource: { ...per },
loading: true,
done: 0,
total: SOURCES.length,
});
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 })
.then((res) => {
if (!alive) return;
collected.push(...res);
per[source.id] = { loading: false, error: null, code: null, count: res.length };
})
.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),
count: 0,
};
})
.finally(() => {
clearTimeout(timer);
ctrl.signal.removeEventListener("abort", onAbort);
if (!alive) return;
done += 1;
setState({
results: sortResults(dedupe(collected.slice())),
perSource: { ...per },
loading: done < SOURCES.length,
done,
total: SOURCES.length,
});
});
}
return () => {
alive = false;
ctrl.abort();
};
}, [query]);
return state;
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect } from "react";
export function useMouseWheel(): void {
useEffect(() => {
const { stdout, stdin } = process;
stdout.write("\x1b[?1000h\x1b[?1006h");
const handler = (data: Buffer): void => {
const str = data.toString("utf8");
const re = /\x1b\[<(64|65);\d+;\d+[Mm]/g;
let match: RegExpExecArray | null;
while ((match = re.exec(str)) !== null) {
const arrow = match[1] === "64" ? "\x1b[A" : "\x1b[B";
process.nextTick(() => stdin.emit("data", Buffer.from(arrow)));
}
};
stdin.prependListener("data", handler);
return () => {
stdout.write("\x1b[?1000l\x1b[?1006l");
stdin.removeListener("data", handler);
};
}, []);
}
+87
View File
@@ -0,0 +1,87 @@
import type { DownloadFocus, Region, Section } from "./store";
export interface Hint {
keys: string;
label: string;
}
interface HelpGroup {
title: string;
hints: Hint[];
}
export const HELP_GROUPS: HelpGroup[] = [
{
title: "Navigate",
hints: [
{ keys: "↑ ↓", label: "Move" },
{ keys: "↵", label: "Open" },
{ keys: "tab", label: "Switch pane" },
{ keys: "esc", label: "Back" },
{ keys: "q", label: "Quit" },
],
},
{
title: "Search",
hints: [
{ keys: "/", label: "Edit search" },
{ keys: "↵", label: "Run search" },
{ keys: "m", label: "Paste magnet" },
],
},
{
title: "Downloads",
hints: [
{ keys: "p", label: "Pause/resume" },
{ keys: "c", label: "Cancel/remove" },
{ keys: "f", label: "Retry failed" },
{ keys: "d", label: "Download again" },
{ keys: "x", label: "Clear recent" },
],
},
];
const ALWAYS: Hint = { keys: "?", label: "Keys" };
const SWITCH: Hint = { keys: "tab", label: "Switch pane" };
export function footerHints(
region: Region,
section: Section,
downloadFocus?: DownloadFocus | null,
): Hint[] {
if (region === "sidebar") {
return [
{ keys: "↑↓", label: "Move" },
{ keys: "↵", label: "Open" },
SWITCH,
ALWAYS,
{ keys: "q", label: "Quit" },
];
}
if (section === "downloads") {
if (downloadFocus === "paused") {
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
}
if (downloadFocus === "failed") {
return [{ keys: "f", label: "Retry" }, { keys: "c", label: "Remove" }, SWITCH, ALWAYS];
}
if (downloadFocus === "recent") {
return [
{ keys: "d", label: "Download again" },
{ keys: "c", label: "Remove" },
{ keys: "x", label: "Clear" },
SWITCH,
ALWAYS,
];
}
return [{ keys: "p", label: "Pause" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
}
return [
{ keys: "d", label: "Download" },
{ keys: "/", label: "Search" },
{ keys: "m", label: "Paste magnet" },
SWITCH,
ALWAYS,
];
}
+9
View File
@@ -0,0 +1,9 @@
export const LOGO_LINES: readonly string[] = [
" 𐓏 ",
" ▀█▀ █▀█ █▀█ █ █ █▄ █ █▄▀",
" █ █▄█ █▀▄ █▄▄ █ █ ▀█ █ █",
];
export const LOGO_WIDTH = Math.max(...LOGO_LINES.map((l) => [...l].length));
export const SPROUT_CELLS: ReadonlySet<string> = new Set(["0,6"]);
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { wrapStep, windowStart } from "./move";
describe("wrapStep", () => {
it("wraps around both ends", () => {
expect(wrapStep(0, -1, 5)).toBe(4);
expect(wrapStep(4, 1, 5)).toBe(0);
expect(wrapStep(2, 1, 5)).toBe(3);
expect(wrapStep(0, 1, 0)).toBe(0);
});
});
describe("windowStart", () => {
it("keeps the cursor centered within bounds", () => {
expect(windowStart(0, 10, 5)).toBe(0);
expect(windowStart(9, 10, 5)).toBe(5);
expect(windowStart(5, 10, 5)).toBe(3);
expect(windowStart(2, 4, 10)).toBe(0);
});
});
+10
View File
@@ -0,0 +1,10 @@
export function wrapStep(current: number, delta: number, length: number): number {
if (length <= 0) return 0;
return (((current + delta) % length) + length) % length;
}
export function windowStart(cursor: number, total: number, height: number): number {
if (total <= height) return 0;
const half = Math.floor(height / 2);
return Math.max(0, Math.min(cursor - half, total - height));
}
+116
View File
@@ -0,0 +1,116 @@
import { createContext, useContext, useEffect, useState } from "react";
import type { Config } from "../config/config";
import type { DownloadQueue } from "../download/queue";
import type { HistoryItem } from "../download/history";
import type { QueueItem } from "../download/types";
import type { SourceGroup, SourceId } from "../sources/types";
export type View = "splash" | "browser";
export type Category = "all" | "games" | "movies" | "tv" | "anime";
export type Section = Category | "downloads";
export const CATEGORIES: { key: Category; label: string; group?: SourceGroup }[] = [
{ key: "all", label: "All" },
{ key: "games", label: "Games", group: "Games" },
{ key: "movies", label: "Movies", group: "Movies" },
{ key: "tv", label: "TV", group: "TV" },
{ key: "anime", label: "Anime", group: "Anime" },
];
export type Region = "sidebar" | "content" | "help";
export type CaptureMode = "none" | "text" | "esc";
export type DownloadFocus = "downloading" | "paused" | "failed" | "recent";
export interface Store {
config: Config;
setConfig: (c: Config) => void;
queue: DownloadQueue;
view: View;
setView: (v: View) => void;
query: string;
submitQuery: (q: string) => void;
section: Section;
setSection: (s: Section) => void;
region: Region;
setRegion: (r: Region) => void;
captureMode: CaptureMode;
setCaptureMode: (m: CaptureMode) => void;
downloadFocus: DownloadFocus | null;
setDownloadFocus: (f: DownloadFocus | null) => void;
startDownload: (input: {
id: string;
name: string;
magnet: string;
source?: SourceId;
sizeBytes?: number;
}) => void;
notice: string | null;
setNotice: (s: string | null) => void;
quitAll: () => void;
listRows: number;
compact: boolean;
contentWidth: number;
cols: number;
rows: number;
}
export const StoreContext = createContext<Store | null>(null);
export function useStore(): Store {
const s = useContext(StoreContext);
if (!s) throw new Error("Store not available");
return s;
}
export function useQueueItems(queue: DownloadQueue): QueueItem[] {
const [items, setItems] = useState<QueueItem[]>(() => queue.getItems());
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
const onUpdate = (): void => {
if (timer) return;
timer = setTimeout(() => {
timer = null;
setItems(queue.getItems());
}, 200);
};
queue.on("update", onUpdate);
onUpdate();
return () => {
queue.off("update", onUpdate);
if (timer) clearTimeout(timer);
};
}, [queue]);
return items;
}
export function useQueueHistory(queue: DownloadQueue): HistoryItem[] {
const [items, setItems] = useState<HistoryItem[]>(() => queue.getHistory());
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
const onUpdate = (): void => {
if (timer) return;
timer = setTimeout(() => {
timer = null;
setItems(queue.getHistory());
}, 200);
};
queue.on("update", onUpdate);
onUpdate();
return () => {
queue.off("update", onUpdate);
if (timer) clearTimeout(timer);
};
}, [queue]);
return items;
}
+58
View File
@@ -0,0 +1,58 @@
import type { SourceId } from "../sources/types";
export const COLOR = {
accent: "#a78bfa",
text: "#e9e4f5",
alt: "#b9a7e6",
good: "#86d6a2",
warn: "#f0c560",
bad: "#ee7d92",
bright: "#d8b4fe",
} as const;
export const ICON = {
done: "✓",
error: "✗",
pending: "·",
pointer: "",
dot: "·",
warn: "⚠",
bar: "▌",
down: "↓",
peer: "•",
pause: "⏸",
} as const;
export const RULE = "#6b6577";
export const GUTTER = 2;
export const SOURCE_STYLE: Record<SourceId, { tag: string; color: string }> = {
fitgirl: { tag: "FG", color: COLOR.accent },
yts: { tag: "YTS", color: COLOR.good },
eztv: { tag: "EZTV", color: COLOR.warn },
nyaa: { tag: "NYAA", color: COLOR.bright },
subsplease: { tag: "SUB", color: "#b9a7e6" },
solid: { tag: "SLD", color: "#60a5fa" },
"tpb-movies": { tag: "TPB", color: "#5fd0c5" },
"tpb-tv": { tag: "TPB", color: "#5fd0c5" },
"x1337-movies": { tag: "1337", color: "#f6a55c" },
"x1337-tv": { tag: "1337", color: "#f6a55c" },
};
function rgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function lerpHex(a: string, b: string, t: number): string {
const [ar, ag, ab] = rgb(a);
const [br, bg, bb] = rgb(b);
const c = (x: number, y: number) =>
Math.round(x + (y - x) * t)
.toString(16)
.padStart(2, "0");
return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
}
export const ACCENT_RAMP: readonly [string, string] = [COLOR.accent, COLOR.bright];
+72
View File
@@ -0,0 +1,72 @@
import { Box, Text, useInput, useStdin } from "ink";
import { Logo } from "../components/Logo";
import { SearchBar } from "../components/SearchBar";
import { LOGO_WIDTH } from "../logo";
import { useStore } from "../store";
import { sourcesByGroup } from "../../sources/registry";
import { COLOR, ICON } from "../theme";
const CATEGORIES = sourcesByGroup()
.map((g) => g.group.toLowerCase())
.join(` ${ICON.dot} `);
export function Splash() {
const { submitQuery, quitAll, cols, rows } = useStore();
const { isRawModeSupported } = useStdin();
useInput(
(input, key) => {
if (key.escape || (key.ctrl && input === "c")) quitAll();
},
{ isActive: isRawModeSupported },
);
const showLogo = cols >= LOGO_WIDTH + 2;
const barWidth = Math.max(24, Math.min(cols - 6, 62));
return (
<Box
height={Math.max(1, rows - 1)}
flexDirection="column"
justifyContent="center"
alignItems="center"
>
{showLogo ? (
<Logo />
) : (
<Text bold color={COLOR.accent}>
torlink
</Text>
)}
<Box marginTop={2}>
<Text color={COLOR.text}>A curated, terminal-native torrent downloader.</Text>
</Box>
<Box>
<Text dimColor>{CATEGORIES}</Text>
</Box>
<Box marginTop={1} width={barWidth}>
<SearchBar
width={barWidth}
value=""
editing
placeholder="Search, or paste a magnet…"
onSubmit={submitQuery}
/>
</Box>
<Box marginTop={1}>
<Text>
<Text color={COLOR.alt}></Text>
<Text dimColor> search</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text dimColor>empty </Text>
<Text color={COLOR.alt}></Text>
<Text dimColor> browse</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>^c</Text>
<Text dimColor> quit</Text>
</Text>
</Box>
</Box>
);
}