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.

torlink's browse view: the sidebar, the search bar, and merged results from every source @@ -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.

torlink's Downloads pane: live progress on top, recently downloaded below 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 ? ( - +