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
+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>
);
}