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, loadSeeds } 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, writeClipboard } from "../util/clipboard"; import { cleanText, truncate } from "../util/format"; import { StoreContext, type CaptureMode, type DownloadFocus, type Region, type Section, type SeedFocus, 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 { Seeding } from "./components/Seeding"; 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(null); const [config, setConfigState] = useState(null); const [view, setView] = useState("splash"); const [query, setQuery] = useState(""); const [section, setSection] = useState
("all"); const [region, setRegion] = useState("content"); const [captureMode, setCaptureMode] = useState("none"); const [downloadFocus, setDownloadFocus] = useState(null); const [seedFocus, setSeedFocus] = useState(null); const [showHelp, setShowHelp] = useState(false); const [notice, setNotice] = useState(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()); q.restoreSeeds(await loadSeeds()); 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(() => { // Flush all state synchronously up front so nothing is lost to the hard // exit; the unmount effect still runs suspend() for the engine teardown. queue?.persistSync(); 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 copyMagnet = useCallback((input: { name: string; magnet: string }) => { void (async () => { const ok = await writeClipboard(input.magnet); if (ok) { setNotice(`Copied magnet: ${truncate(cleanText(input.magnet), 60)}`); return; } setNotice(`Couldn't copy magnet for ${truncate(cleanText(input.name), 32)}.`); })(); }, []); 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, seedFocus, setSeedFocus, startDownload, copyMagnet, notice, setNotice, quitAll, listRows, compact, contentWidth, cols, rows, }; }, [ queue, config, view, query, submitQuery, section, region, showHelp, captureMode, downloadFocus, seedFocus, startDownload, copyMagnet, 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 ( ); } if (view === "splash") { return ( ); } return ( {notice ? {notice} : null} {showTopRule ? : null} {showHelp ? ( ) : null} {section === "downloads" ? ( ) : section === "seeding" ? ( ) : ( )} {showFooter ? (