feat: polish prompt ux and fix z hiding sources without seeder data

- new PromptHints action row shared by the folder and trackers prompts
- download-to prompt shows the torrent name and size, enter reads "download"
- export torrent rebound from T to s and centralized in the store
- footer trimmed to essentials, d/D and z detail live in the ? sheet
- z keeps FitGirl and SubsPlease rows: their feeds carry no seeder data,
  a new per-source reportsHealth flag marks seeders: 0 as unknown, not dead
- version 1.3.0
This commit is contained in:
bairon.dev
2026-07-07 01:55:04 -04:00
parent 26a4f0f910
commit 3302f46762
21 changed files with 126 additions and 72 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "torlnk",
"version": "1.1.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "torlnk",
"version": "1.1.1",
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"env-paths": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "torlnk",
"version": "1.2.0",
"version": "1.3.0",
"description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.",
"type": "module",
"bin": {
+1
View File
@@ -100,6 +100,7 @@ function makeStore(
requestDownloadTo: noop,
copyMagnet: noop,
openDownloadFolder: noop,
exportTorrent: noop,
notice: null,
setNotice: noop,
quitAll: noop,
+1
View File
@@ -54,5 +54,6 @@ export const eztv: Source = {
label: "EZTV",
group: "TV",
homepage: "https://eztvx.to",
reportsHealth: true,
search,
};
+2
View File
@@ -8,5 +8,7 @@ export const fitgirl: Source = {
label: "FitGirl",
group: "Games",
homepage: HOME,
// WordPress RSS carries no swarm data; every result reports seeders: 0.
reportsHealth: false,
search: (query, opts) => fetchWordpressRss(HOME, "fitgirl", query, opts),
};
+1
View File
@@ -46,5 +46,6 @@ export const nyaa: Source = {
label: "Nyaa",
group: "Anime",
homepage: "https://nyaa.si",
reportsHealth: true,
search,
};
+2
View File
@@ -79,6 +79,7 @@ export const tpbMovies: Source = {
label: "TPB",
group: "Movies",
homepage: "https://thepiratebay.org",
reportsHealth: true,
search: (query, opts = {}) => search(query, MOVIE_CATS, TOP_MOVIES, "tpb-movies", opts),
};
@@ -87,5 +88,6 @@ export const tpbTv: Source = {
label: "TPB",
group: "TV",
homepage: "https://thepiratebay.org",
reportsHealth: true,
search: (query, opts = {}) => search(query, TV_CATS, TOP_TV, "tpb-tv", opts),
};
+2
View File
@@ -71,5 +71,7 @@ export const subsplease: Source = {
label: "SubsPlease",
group: "Anime",
homepage: "https://subsplease.org",
// The SubsPlease API has no swarm data; every result reports seeders: 0.
reportsHealth: false,
search,
};
+4
View File
@@ -32,5 +32,9 @@ export interface Source {
label: string;
group: SourceGroup;
homepage: string;
// True when the source returns real swarm counts. False when its feed has
// none, so seeders: 0 means unknown, not dead (the alive-only filter must
// never drop those rows).
reportsHealth: boolean;
search(query: string, opts?: SearchOptions): Promise<TorrentResult[]>;
}
+2
View File
@@ -142,6 +142,7 @@ export const x1337Movies: Source = {
label: "1337x",
group: "Movies",
homepage: "https://1337x.to",
reportsHealth: true,
search: (query, opts = {}) => search(query, "Movies", "x1337-movies", opts),
};
@@ -150,5 +151,6 @@ export const x1337Tv: Source = {
label: "1337x",
group: "TV",
homepage: "https://1337x.to",
reportsHealth: true,
search: (query, opts = {}) => search(query, "TV", "x1337-tv", opts),
};
+1
View File
@@ -76,5 +76,6 @@ export const yts: Source = {
label: "YTS",
group: "Movies",
homepage: "https://yts.mx",
reportsHealth: true,
search,
};
+24 -1
View File
@@ -11,7 +11,7 @@ import { parseInput } from "../sources/magnet";
import { magnetFromTorrentFile } from "../sources/torrentFile";
import { readClipboard, writeClipboard } from "../util/clipboard";
import { openFolder } from "../util/openFolder";
import { cleanText, truncate } from "../util/format";
import { cleanText, formatBytes, truncate } from "../util/format";
import {
StoreContext,
type CaptureMode,
@@ -307,6 +307,21 @@ export function App({
})();
}, []);
const exportTorrent = useCallback(
(input: { id: string; name: string }) => {
if (!queue) return;
void (async () => {
const file = await queue.exportTorrentFile(input.id);
if (file) {
setNotice(`Exported torrent file: ${truncate(file, 48)}`);
return;
}
setNotice(`No torrent file yet for ${truncate(cleanText(input.name), 32)}.`);
})();
},
[queue],
);
const submitQuery = useCallback(
(raw: string) => {
const q = raw.trim();
@@ -389,6 +404,7 @@ export function App({
requestDownloadTo,
copyMagnet,
openDownloadFolder,
exportTorrent,
notice,
setNotice,
quitAll,
@@ -417,6 +433,7 @@ export function App({
requestDownloadTo,
copyMagnet,
openDownloadFolder,
exportTorrent,
notice,
listRows,
compact,
@@ -546,6 +563,12 @@ export function App({
<FolderPrompt
title="download to"
width={Math.max(24, Math.min(cols - 4, 62))}
subject={
pendingDownload.sizeBytes
? `${cleanText(pendingDownload.name)} ${ICON.dot} ${formatBytes(pendingDownload.sizeBytes)}`
: cleanText(pendingDownload.name)
}
submitLabel="download"
value={lastDownloadToDir ?? store.config.downloadDir}
onSubmit={startDownloadTo}
onCancel={closeDownloadToPrompt}
+3 -8
View File
@@ -52,7 +52,7 @@ export function Downloads() {
startDownload,
openDownloadFolder,
setDownloadFocus,
setNotice,
exportTorrent,
} = useStore();
const active = useQueueItems(queue);
const recent = useQueueHistory(queue);
@@ -73,14 +73,9 @@ export function Downloads() {
else if (input === "e") {
const dir = inActive ? active[clamped]?.dir : recent[recentCursor]?.dir;
if (dir) openDownloadFolder(dir);
} else if (input === "T") {
} else if (input === "s") {
const item = inActive ? active[clamped] : recent[recentCursor];
if (!item) return;
void (async () => {
const file = await queue.exportTorrentFile(item.id);
if (file) setNotice(`Exported .torrent: ${truncate(file, 48)}`);
else setNotice(`No .torrent metadata yet for ${truncate(cleanText(item.name), 32)}.`);
})();
if (item) exportTorrent({ id: item.id, name: item.name });
} else if (inActive) {
const it = active[clamped];
if (!it) return;
+20 -7
View File
@@ -1,12 +1,20 @@
import { Box, Text, useInput } from "ink";
import { TextField } from "./TextField";
import { Panel } from "./Panel";
import { PromptHints } from "./PromptHints";
import { COLOR, ICON } from "../theme";
interface FolderPromptProps {
width: number;
value: string;
title?: string;
// One dim context line above the input, e.g. the torrent this download is
// for. Without it the prompt is a bare path box with no hint of what the
// user is placing.
subject?: string;
// Verb next to ↵; the default fits the settings prompt, the per-download
// prompt passes "download" so enter reads as the action it performs.
submitLabel?: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}
@@ -14,7 +22,9 @@ interface FolderPromptProps {
export function FolderPrompt({
width,
value,
title = "download folder",
title = "default download folder",
subject,
submitLabel = "save",
onSubmit,
onCancel,
}: FolderPromptProps) {
@@ -24,7 +34,14 @@ export function FolderPrompt({
return (
<Box flexDirection="column" width={width}>
<Panel title={title} width={width} focused height={2}>
<Panel title={title} width={width} focused height={subject ? 3 : 2}>
{subject ? (
<Box>
<Text dimColor wrap="truncate-end">
{subject}
</Text>
</Box>
) : null}
<Box>
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
<Box flexGrow={1} minWidth={0}>
@@ -37,11 +54,7 @@ export function FolderPrompt({
</Box>
</Panel>
<Box marginTop={1}>
<Text color={COLOR.alt}></Text>
<Text dimColor> save</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> cancel</Text>
<PromptHints submitLabel={submitLabel} />
</Box>
</Box>
);
+18
View File
@@ -0,0 +1,18 @@
import { Box, Text } from "ink";
import { COLOR, ICON } from "../theme";
// The one-line action row under a modal prompt: the submit verb carries the
// visual weight, esc stays quiet.
export function PromptHints({ submitLabel }: { submitLabel: string }) {
return (
<Box>
<Text color={COLOR.accent} bold>
</Text>
<Text color={COLOR.text}>{` ${submitLabel}`}</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> cancel</Text>
</Box>
);
}
+4 -4
View File
@@ -93,15 +93,15 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
</Box>
<Box marginTop={1}>
<Text color={COLOR.accent} bold>
d/D
d
</Text>
<Text color={COLOR.text}> Download</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.accent} bold>
y
</Text>
<Text color={COLOR.text}> Copy magnet</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.text}> Copy</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> back</Text>
</Box>
+9 -12
View File
@@ -1,6 +1,7 @@
import { Box, Text, useInput } from "ink";
import { TextField } from "./TextField";
import { Panel } from "./Panel";
import { PromptHints } from "./PromptHints";
import { formatTrackers, parseTrackers } from "../../config/trackers";
import { COLOR, ICON } from "../theme";
@@ -18,7 +19,12 @@ export function TrackersPrompt({ width, value, onSubmit, onCancel }: TrackersPro
return (
<Box flexDirection="column" width={width}>
<Panel title="extra trackers" width={width} focused height={2}>
<Panel title="extra trackers" width={width} focused height={3}>
<Box>
<Text dimColor wrap="truncate-end">
Comma or space separated. Empty clears. Applies to new adds.
</Text>
</Box>
<Box>
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
<Box flexGrow={1} minWidth={0}>
@@ -30,17 +36,8 @@ export function TrackersPrompt({ width, value, onSubmit, onCancel }: TrackersPro
</Box>
</Box>
</Panel>
<Box marginTop={1} flexDirection="column">
<Box>
<Text color={COLOR.alt}></Text>
<Text dimColor> save</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> cancel</Text>
</Box>
<Text dimColor>
Separate with commas or spaces. Empty saves an empty list. Applies to new adds.
</Text>
<Box marginTop={1}>
<PromptHints submitLabel="save" />
</Box>
</Box>
);
+10
View File
@@ -29,6 +29,16 @@ describe("filterResults", () => {
expect(filterResults(list, true).map((x) => x.infoHash)).toEqual(["b"]);
});
it("keeps zero-seeder rows from sources that report no health data", () => {
const list = [
r({ infoHash: "a", seeders: 0, source: "fitgirl" }),
r({ infoHash: "b", seeders: 0 }),
r({ infoHash: "c", seeders: 0, source: "subsplease" }),
r({ infoHash: "d", seeders: 3 }),
];
expect(filterResults(list, true).map((x) => x.infoHash)).toEqual(["a", "c", "d"]);
});
it("does not mutate the input array", () => {
const list = [r({ infoHash: "a", seeders: 0 }), r({ infoHash: "b", seeders: 2 })];
const before = list.map((x) => x.infoHash);
+4 -1
View File
@@ -1,3 +1,4 @@
import { getSource } from "../sources/registry";
import type { TorrentResult } from "../sources/types";
export function filterResults(
@@ -5,5 +6,7 @@ export function filterResults(
hideDead: boolean,
): TorrentResult[] {
if (!hideDead) return list;
return list.filter((r) => r.seeders > 0);
// Sources without swarm data report seeders: 0 for everything (unknown, not
// dead), so the filter only judges rows whose source actually reports health.
return list.filter((r) => r.seeders > 0 || !getSource(r.source).reportsHealth);
}
+12 -36
View File
@@ -18,7 +18,7 @@ export const HELP_GROUPS: HelpGroup[] = [
{ keys: "↵", label: "Open" },
{ keys: "tab", label: "Switch pane" },
{ keys: "esc", label: "Back" },
{ keys: "o", label: "Download folder" },
{ keys: "o", label: "Default download folder" },
{ keys: "t", label: "Extra trackers" },
{ keys: "q", label: "Quit" },
],
@@ -27,9 +27,7 @@ export const HELP_GROUPS: HelpGroup[] = [
title: "Search",
hints: [
{ keys: "/", label: "Edit search" },
{ keys: "", label: "Run search" },
{ keys: "d", label: "Download" },
{ keys: "D", label: "Download to a folder…" },
{ keys: "d/D", label: "Download (D picks folder)" },
{ keys: "s", label: "Sort results" },
{ keys: "z", label: "Hide dead torrents" },
{ keys: "y", label: "Copy magnet" },
@@ -44,7 +42,7 @@ export const HELP_GROUPS: HelpGroup[] = [
{ keys: "f", label: "Retry failed" },
{ keys: "d", label: "Download again" },
{ keys: "e", label: "Open folder" },
{ keys: "T", label: "Export .torrent" },
{ keys: "s", label: "Export torrent file" },
{ keys: "x", label: "Clear recent" },
],
},
@@ -59,7 +57,8 @@ export const HELP_GROUPS: HelpGroup[] = [
];
// Footer labels stay terse so the contextual hint row never wraps; the `?`
// overlay (HELP_GROUPS) carries the full, descriptive list.
// overlay (HELP_GROUPS) carries the full, descriptive list. Rare or
// self-announcing actions (z) stay `?`-only to keep every row inside 80 cols.
const NAVIGATE: Hint = { keys: "↑↓←→", label: "Move" };
const ALWAYS: Hint = { keys: "?", label: "Keys" };
@@ -68,7 +67,7 @@ const SWITCH: Hint = { keys: "tab", label: "Switch" };
const FOLDER: Hint = { keys: "e", label: "Folder" };
const TORRENT: Hint = { keys: "T", label: "Torrent" };
const TORRENT: Hint = { keys: "s", label: "Export" };
export function footerHints(
region: Region,
@@ -92,28 +91,13 @@ export function footerHints(
}
if (section === "downloads") {
if (downloadFocus === "paused") {
return [
{ keys: "p", label: "Resume" },
{ keys: "c", label: "Cancel" },
FOLDER,
TORRENT,
SWITCH,
ALWAYS,
];
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, FOLDER, TORRENT, SWITCH, ALWAYS];
}
if (downloadFocus === "failed") {
return [
{ keys: "f", label: "Retry" },
{ keys: "c", label: "Remove" },
FOLDER,
TORRENT,
SWITCH,
ALWAYS,
];
return [{ keys: "f", label: "Retry" }, { keys: "c", label: "Remove" }, FOLDER, TORRENT, SWITCH, ALWAYS];
}
if (downloadFocus === "recent") {
return [
NAVIGATE,
{ keys: "d", label: "Redownload" },
{ keys: "c", label: "Remove" },
{ keys: "x", label: "Clear" },
@@ -123,23 +107,15 @@ export function footerHints(
ALWAYS,
];
}
return [
{ keys: "p", label: "Pause" },
{ keys: "c", label: "Cancel" },
FOLDER,
TORRENT,
SWITCH,
ALWAYS,
];
return [{ keys: "p", label: "Pause" }, { keys: "c", label: "Cancel" }, FOLDER, TORRENT, SWITCH, ALWAYS];
}
return [
NAVIGATE,
// d downloads to the default folder, D asks where; the `?` sheet spells
// out the difference, the footer just shows both keys exist.
{ keys: "d/D", label: "Download" },
// The footer advertises only the default download key; D (download to a
// chosen folder) stays bound but lives in the `?` sheet alone.
{ keys: "d", label: "Download" },
{ keys: "y", label: "Copy" },
{ keys: "s", label: "Sort" },
{ keys: "z", label: "Alive" },
{ keys: "/", label: "Search" },
SWITCH,
ALWAYS,
+3
View File
@@ -67,6 +67,9 @@ export interface Store {
}) => void;
copyMagnet: (input: { name: string; magnet: string }) => void;
openDownloadFolder: (dir: string) => void;
// Copies the cached .torrent metadata into the item's download folder and
// reports the outcome through the notice line.
exportTorrent: (input: { id: string; name: string }) => void;
notice: string | null;
setNotice: (s: string | null) => void;