From 28565a88dd1634184b7c9de1736fecb332e17d66 Mon Sep 17 00:00:00 2001
From: TechNapoleon <117212926+TechNapoleon@users.noreply.github.com>
Date: Sun, 5 Jul 2026 21:57:32 +0300
Subject: [PATCH] feat: pick a folder per download with D (#54)
The o key sets one global download folder, so routing a single result
somewhere else (a movie to the NAS, a game to the big drive) meant
changing the setting and changing it back. D on a result now opens a
"download to" prompt for just that item; d stays a one-keypress
download to the default.
The queue already stores a dir per item, so resume, re-seed and
download-again honor the choice with no new plumbing. The prompt
reuses FolderPrompt (new title prop), pre-fills with the last D folder
this session so batches cost one typed path, and nothing new is
persisted. The ? sheet gains d/D entries (d was missing entirely) and
the README now mentions both o and D, which it never documented.
One behavior fix rides along because D exposed it: queue.add() used to
ignore the requested dir when re-adding a failed item, so redirecting
a failed download (the disk-full case D exists for) silently retried
into the old folder. add() now adopts the requested dir on failed
retry, dropping resume progress only when the folder actually changed;
covered in queue.add.test.ts with the engine mocked. The f retry key
goes through queue.retry() and keeps the item's own folder, unchanged.
D on an already-active item says "Already in queue" instead of
claiming a folder that wouldn't be used.
---
README.md | 4 +-
preview/browse.svg | 24 ++++-----
scripts/render-previews-impl.tsx | 1 +
src/download/queue.add.test.ts | 75 +++++++++++++++++++++++++++
src/download/queue.ts | 14 ++++-
src/ui/App.tsx | 82 ++++++++++++++++++++++++++++--
src/ui/components/FolderPrompt.tsx | 11 +++-
src/ui/components/Results.tsx | 16 +++++-
src/ui/keymap.ts | 6 ++-
src/ui/store.ts | 9 ++++
10 files changed, 219 insertions(+), 23 deletions(-)
create mode 100644 src/download/queue.add.test.ts
diff --git a/README.md b/README.md
index e84038f..96497b1 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ That's the only thing you'll type. torlink opens straight to a search bar: searc
## Finding something
-Type what you're looking for and press Enter. Results stream in from every source as they answer, tagged with size and how many people are sharing each one, so you can see what'll come down fast. Arrow to what you want and press `d` to save it.
+Type what you're looking for and press Enter. Results stream in from every source as they answer, tagged with size and how many people are sharing each one, so you can see what'll come down fast. Arrow to what you want and press `d` to save it, or `D` to pick a different folder for just that download.
@@ -30,7 +30,7 @@ Type what you're looking for and press Enter. Results stream in from every sourc
Active downloads sit up top with their progress, speed, and time left; when one finishes it drops into Recently downloaded just below, so the list stays tidy. Everything's still there when you come back, and anything interrupted picks up where it left off.
-Downloads run in the background while you keep searching, so you can queue up as many as you want. They save to your downloads folder, and the Downloads pane keeps tabs on each one. When something finishes it keeps seeding automatically so the next person can find it too, and the Seeding tab lets you pause or stop that anytime.
+Downloads run in the background while you keep searching, so you can queue up as many as you want. They save to your downloads folder — press `o` anytime to change where that is, or grab one result with `D` to send it somewhere else without touching the default — and the Downloads pane keeps tabs on each one. When something finishes it keeps seeding automatically so the next person can find it too, and the Seeding tab lets you pause or stop that anytime.
diff --git a/preview/browse.svg b/preview/browse.svg
index 951a1c8..7302068 100644
--- a/preview/browse.svg
+++ b/preview/browse.svg
@@ -109,17 +109,17 @@
╰───────────────────────────────────────────────────────────╯
↑↓←→
Move
- d
- Download
- y
- Copy
- s
- Sort
- /
- Search
- tab
- Switch
- ?
- Keys
+ d/D
+ Download
+ y
+ Copy
+ s
+ Sort
+ /
+ Search
+ tab
+ Switch
+ ?
+ Keys
\ No newline at end of file
diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx
index 1557199..0345b2a 100644
--- a/scripts/render-previews-impl.tsx
+++ b/scripts/render-previews-impl.tsx
@@ -97,6 +97,7 @@ function makeStore(
seedFocus: null,
setSeedFocus: noop,
startDownload: noop,
+ requestDownloadTo: noop,
copyMagnet: noop,
openDownloadFolder: noop,
notice: null,
diff --git a/src/download/queue.add.test.ts b/src/download/queue.add.test.ts
new file mode 100644
index 0000000..149e106
--- /dev/null
+++ b/src/download/queue.add.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi } from "vitest";
+import { DownloadQueue } from "./queue";
+import type { QueueItem } from "./types";
+
+// add() drives the engine, which would spin up webtorrent and touch the
+// network. Stub it (the way clipboard.test.ts stubs node:child_process) so
+// these tests cover the queue's own bookkeeping. Kept out of queue.test.ts,
+// whose tests prove they never reach the engine by construction.
+vi.mock("./engine", () => ({
+ TorrentEngine: class {
+ add(): void {}
+ remove(): void {}
+ stats(): undefined {
+ return undefined;
+ }
+ destroy(): void {}
+ },
+}));
+
+function failedItem(over: Partial = {}): QueueItem {
+ return {
+ id: "t1",
+ name: "Some Torrent",
+ magnet: "magnet:?xt=urn:btih:0000000000000000000000000000000000000000",
+ dir: "/downloads/a",
+ status: "failed",
+ progress: 40,
+ totalBytes: 100,
+ downloadedBytes: 40,
+ speed: 0,
+ peers: 0,
+ error: "boom",
+ addedAt: 1,
+ ...over,
+ };
+}
+
+const input = { id: "t1", name: "Some Torrent", magnet: failedItem().magnet };
+
+describe("DownloadQueue.add retry semantics", () => {
+ it("re-adds a failed item into the newly requested dir, dropping stale resume progress", () => {
+ const q = new DownloadQueue();
+ q.restore([failedItem()]);
+ q.add(input, "/downloads/b");
+ const it = q.getItems()[0]!;
+ expect(it.status).toBe("downloading");
+ expect(it.dir).toBe("/downloads/b");
+ expect(it.progress).toBe(0);
+ expect(it.downloadedBytes).toBe(0);
+ expect(it.error).toBeUndefined();
+ q.suspend();
+ });
+
+ it("keeps resume progress when a failed item retries into the same dir", () => {
+ const q = new DownloadQueue();
+ q.restore([failedItem()]);
+ q.add(input, "/downloads/a");
+ const it = q.getItems()[0]!;
+ expect(it.status).toBe("downloading");
+ expect(it.dir).toBe("/downloads/a");
+ expect(it.progress).toBe(40);
+ expect(it.downloadedBytes).toBe(40);
+ q.suspend();
+ });
+
+ it("leaves an active download untouched when re-added with a different dir", () => {
+ const q = new DownloadQueue();
+ q.restore([failedItem({ status: "downloading", error: undefined })]);
+ q.add(input, "/downloads/b");
+ const it = q.getItems()[0]!;
+ expect(it.dir).toBe("/downloads/a");
+ expect(it.progress).toBe(40);
+ q.suspend();
+ });
+});
diff --git a/src/download/queue.ts b/src/download/queue.ts
index 5d02d02..a84461b 100644
--- a/src/download/queue.ts
+++ b/src/download/queue.ts
@@ -87,7 +87,19 @@ export class DownloadQueue extends EventEmitter {
const existing = this.items.get(input.id);
if (existing && existing.status !== "failed") return;
const item: QueueItem = existing
- ? { ...existing, status: "downloading", error: undefined, speed: 0 }
+ ? {
+ ...existing,
+ // A re-add is a fresh request, so it targets the dir asked for now.
+ // Partial data doesn't follow to a new folder, so resume progress
+ // only survives when the dir is unchanged.
+ dir,
+ status: "downloading",
+ error: undefined,
+ speed: 0,
+ ...(existing.dir === dir
+ ? {}
+ : { progress: 0, downloadedBytes: 0, eta: undefined }),
+ }
: {
id: input.id,
name: input.name,
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 6209dbc..faf890b 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -86,6 +86,17 @@ export function App({
const [showHelp, setShowHelp] = useState(false);
const [editingFolder, setEditingFolder] = useState(false);
const [editingTrackers, setEditingTrackers] = useState(false);
+ // A result waiting on the "download to" prompt (D); null when the prompt is
+ // closed. lastDownloadToDir pre-fills the next prompt so queueing a batch
+ // into the same alternate folder only costs one typed path per session.
+ const [pendingDownload, setPendingDownload] = useState<{
+ id: string;
+ name: string;
+ magnet: string;
+ source?: SourceId;
+ sizeBytes?: number;
+ } | null>(null);
+ const [lastDownloadToDir, setLastDownloadToDir] = useState(null);
const [notice, setNotice] = useState(null);
const booting = useRef(false);
@@ -226,6 +237,54 @@ export function App({
[config, queue],
);
+ const requestDownloadTo = useCallback(
+ (input: {
+ id: string;
+ name: string;
+ magnet: string;
+ source?: SourceId;
+ sizeBytes?: number;
+ }) => {
+ setPendingDownload(input);
+ },
+ [],
+ );
+
+ const closeDownloadToPrompt = useCallback(() => {
+ setPendingDownload(null);
+ }, []);
+
+ const startDownloadTo = useCallback(
+ (raw: string) => {
+ const input = pendingDownload;
+ setPendingDownload(null);
+ const dir = normalizeDownloadDir(raw);
+ if (!queue || !input || !dir) return;
+ // add() ignores the dir for anything already active, so don't claim a
+ // folder that won't be used. Failed items fall through: a re-add with a
+ // fresh dir is exactly how a bad-disk download gets redirected.
+ const existing = queue.getItems().find((it) => it.id === input.id);
+ if (existing && existing.status !== "failed") {
+ setNotice(`Already in queue: ${truncate(cleanText(input.name), 40)}`);
+ return;
+ }
+ void (async () => {
+ try {
+ await fs.mkdir(dir, { recursive: true });
+ } catch {
+ setNotice(`Couldn't use folder: ${truncate(dir, 48)}`);
+ return;
+ }
+ setLastDownloadToDir(dir);
+ queue.add(input, dir);
+ setNotice(`Added: ${truncate(cleanText(input.name), 28)} → ${truncate(dir, 36)}`);
+ setSection("downloads");
+ setRegion("content");
+ })();
+ },
+ [queue, pendingDownload],
+ );
+
const copyMagnet = useCallback((input: { name: string; magnet: string }) => {
void (async () => {
const ok = await writeClipboard(input.magnet);
@@ -318,7 +377,7 @@ export function App({
submitQuery,
section,
setSection,
- region: showHelp || editingFolder || editingTrackers ? "help" : region,
+ region: showHelp || editingFolder || editingTrackers || pendingDownload ? "help" : region,
setRegion,
captureMode,
setCaptureMode,
@@ -327,6 +386,7 @@ export function App({
seedFocus,
setSeedFocus,
startDownload,
+ requestDownloadTo,
copyMagnet,
openDownloadFolder,
notice,
@@ -349,10 +409,12 @@ export function App({
showHelp,
editingFolder,
editingTrackers,
+ pendingDownload,
captureMode,
downloadFocus,
seedFocus,
startDownload,
+ requestDownloadTo,
copyMagnet,
openDownloadFolder,
notice,
@@ -371,7 +433,7 @@ export function App({
quitAll();
return;
}
- if (editingFolder || editingTrackers) return; // the prompt owns input (its own esc + enter)
+ if (editingFolder || editingTrackers || pendingDownload) return; // the prompt owns input (its own esc + enter)
if (captureMode === "text") return;
if (showHelp) {
setShowHelp(false);
@@ -479,10 +541,22 @@ export function App({
) : null}
+ {pendingDownload ? (
+
+
+
+ ) : null}
+
@@ -498,7 +572,7 @@ export function App({
{showFooter ? (
-
+
) : null}
diff --git a/src/ui/components/FolderPrompt.tsx b/src/ui/components/FolderPrompt.tsx
index ad77f30..aecd5b5 100644
--- a/src/ui/components/FolderPrompt.tsx
+++ b/src/ui/components/FolderPrompt.tsx
@@ -6,18 +6,25 @@ import { COLOR, ICON } from "../theme";
interface FolderPromptProps {
width: number;
value: string;
+ title?: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}
-export function FolderPrompt({ width, value, onSubmit, onCancel }: FolderPromptProps) {
+export function FolderPrompt({
+ width,
+ value,
+ title = "download folder",
+ onSubmit,
+ onCancel,
+}: FolderPromptProps) {
useInput((_input, key) => {
if (key.escape) onCancel();
});
return (
-
+
{`${ICON.pointer} `}
diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx
index e305a43..5009887 100644
--- a/src/ui/components/Results.tsx
+++ b/src/ui/components/Results.tsx
@@ -92,7 +92,7 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
- d
+ d/D
Download
{` ${ICON.dot} `}
@@ -117,6 +117,7 @@ export function Results() {
setRegion,
setCaptureMode,
startDownload,
+ requestDownloadTo,
copyMagnet,
contentWidth,
listRows,
@@ -168,6 +169,15 @@ export function Results() {
sizeBytes: r.sizeBytes,
});
+ const openDownloadTo = (r: TorrentResult): void =>
+ requestDownloadTo({
+ id: r.infoHash,
+ name: r.name,
+ magnet: r.magnet,
+ source: r.source,
+ sizeBytes: r.sizeBytes,
+ });
+
const copyResultMagnet = (r: TorrentResult): void =>
copyMagnet({ name: r.name, magnet: r.magnet });
@@ -195,6 +205,9 @@ export function Results() {
} else if (input === "d") {
const r = results[clamped];
if (r) openDownload(r);
+ } else if (input === "D") {
+ const r = results[clamped];
+ if (r) openDownloadTo(r);
} else if (input === "y") {
const r = results[clamped];
if (r) copyResultMagnet(r);
@@ -211,6 +224,7 @@ export function Results() {
setMode("list");
setDetail(null);
} else if (input === "d" && detail) openDownload(detail);
+ else if (input === "D" && detail) openDownloadTo(detail);
else if (input === "y" && detail) copyResultMagnet(detail);
},
{ isActive: focused && mode === "detail" },
diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts
index 9189cfd..7f118c0 100644
--- a/src/ui/keymap.ts
+++ b/src/ui/keymap.ts
@@ -28,6 +28,8 @@ export const HELP_GROUPS: HelpGroup[] = [
hints: [
{ keys: "/", label: "Edit search" },
{ keys: "↵", label: "Run search" },
+ { keys: "d", label: "Download" },
+ { keys: "D", label: "Download to a folder…" },
{ keys: "s", label: "Sort results" },
{ keys: "y", label: "Copy magnet" },
{ keys: "m", label: "Paste magnet" },
@@ -106,7 +108,9 @@ export function footerHints(
}
return [
NAVIGATE,
- { keys: "d", label: "Download" },
+ // 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" },
{ keys: "y", label: "Copy" },
{ keys: "s", label: "Sort" },
{ keys: "/", label: "Search" },
diff --git a/src/ui/store.ts b/src/ui/store.ts
index 3b3d1eb..ac1a174 100644
--- a/src/ui/store.ts
+++ b/src/ui/store.ts
@@ -56,6 +56,15 @@ export interface Store {
source?: SourceId;
sizeBytes?: number;
}) => void;
+ // Opens the "download to" prompt (D) so this one download can land in a
+ // folder other than the configured default.
+ requestDownloadTo: (input: {
+ id: string;
+ name: string;
+ magnet: string;
+ source?: SourceId;
+ sizeBytes?: number;
+ }) => void;
copyMagnet: (input: { name: string; magnet: string }) => void;
openDownloadFolder: (dir: string) => void;