mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
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:
+366
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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"]);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user