diff --git a/.gitignore b/.gitignore
index c073aad..7e80a4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ node_modules/
dist/
.DS_Store
package-lock.json
+bun.lock
+.bun/
\ No newline at end of file
diff --git a/preview/browse.svg b/preview/browse.svg
index 951a1c8..40057b4 100644
--- a/preview/browse.svg
+++ b/preview/browse.svg
@@ -4,122 +4,122 @@
torlink
- ๐
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- All
- โญโ
- Search
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+ ๐
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ All
+ โญโ
+ Search
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
Games
-
- โฏ
+
+ โฏ
Search or paste a magnet linkโฆ
-
+
Movies
- โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
+ โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
TV
Anime
- โญโ
- Latest (5)
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
-
+ โญโ
+ Latest (5)
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+
newest across all sources
-
+
Downloads
-
-
+
+
Seeding
-
+
#
Name
Size
Seed:Lch
Src
-
-
- โฏ
+
+
+ โฏ
1
- Oppenheimer (2023) [1080pโฆ
+ Oppenheimer (2023) [1080pโฆ
1.96 GB
- 1240:88
- YTS
-
-
+ 1240:88
+ YTS
+
+
2
Dune: Part Two (2024) [21โฆ
7.82 GB
- 910:41
- YTS
-
-
+ 910:41
+ YTS
+
+
3
Breaking Bad S05E14 1080pโฆ
1.49 GB
- 540:31
- EZTV
-
-
+ 540:31
+ EZTV
+
+
4
[Erai-raws] Jujutsu Kaiseโฆ
1.21 GB
- 320:12
- NYAA
-
-
+ 320:12
+ NYAA
+
+
5
Frieren - 28 [1080p]
1.30 GB
-
- SUB
-
- โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
- โโโโ
+ SUB
+
+ โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
+ โโโโ
Move
- d
+ d
Download
- y
+ y
Copy
- s
+ s
Sort
- /
+ /
Search
- tab
+ tab
Switch
- ?
+ ?
Keys
\ No newline at end of file
diff --git a/preview/downloads.svg b/preview/downloads.svg
index 7c8c339..d4d933d 100644
--- a/preview/downloads.svg
+++ b/preview/downloads.svg
@@ -4,151 +4,151 @@
torlink
- ๐
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ ๐
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
All
- โญโ
- Downloads (1)
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+ โญโ
+ Downloads (1)
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
Games
-
- โฏ
- โ
- Dune: Part Two (2024) [2160p BluRay]
+
+ โฏ
+ โ
+ Dune: Part Two (2024) [2160p BluRay]
7.82 GB
- YTS
-
+ YTS
+
Movies
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- โโโโโโโโ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ โโโโโโโโ
64% 7.7 MB/s โข41 6m
-
+
TV
-
-
+
+
Anime
-
+
Recently downloaded (2)
-
-
- โ
+
+
+ โ
Elden Ring: Shadow of tโฆ
50.29 GB
1hr ago
- FG
-
-
- Downloads
+ FG
+
+
+ Downloads
(1)
-
- โ
+
+ โ
Breaking Bad S05E14 108โฆ
1.49 GB
1d 1hr ago
- EZTV
-
+ EZTV
+
Seeding
-
-
-
-
- โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
- p
+
+
+
+
+ โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
+ p
Pause
- c
+ c
Cancel
- tab
+ tab
Switch
- ?
+ ?
Keys
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
\ No newline at end of file
diff --git a/preview/splash.svg b/preview/splash.svg
index bd904ab..f705b35 100644
--- a/preview/splash.svg
+++ b/preview/splash.svg
@@ -4,57 +4,57 @@
torlink
- ๐
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- A curated, terminal-native torrent downloader.
+ ๐
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A curated, terminal-native torrent downloader.
games ยท movies ยท tv ยท anime
- โญโ
- Search
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
-
- โฏ
+ โญโ
+ Search
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+
+ โฏ
S
earch or paste a magnet linkโฆ
-
- โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
- โต
+
+ โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
+ โต
search ยท empty
- โต
+ โต
browse ยท
- ^c
+ ^c
quit
\ No newline at end of file
diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx
index d440196..f4ba5c6 100644
--- a/scripts/render-previews-impl.tsx
+++ b/scripts/render-previews-impl.tsx
@@ -260,3 +260,5 @@ save(
,
{ shimmer: true },
);
+
+
diff --git a/src/download/engine.ts b/src/download/engine.ts
index a454e52..f0c80bb 100644
--- a/src/download/engine.ts
+++ b/src/download/engine.ts
@@ -12,10 +12,16 @@ export interface TorrentProgress {
name: string;
}
+export interface TorrentFileMeta {
+ path: string;
+ length: number;
+}
+
export interface TorrentMeta {
name: string;
total: number;
files: number;
+ fileList: TorrentFileMeta[];
// The .torrent metadata (piece hashes), available once metadata arrives. We
// persist it so a later re-seed can verify the on-disk file without having to
// re-fetch metadata from the swarm (which a bare magnet would require).
@@ -80,6 +86,7 @@ export class TorrentEngine {
name: torrent.name,
total: torrent.length,
files: torrent.files?.length ?? 0,
+ fileList: (torrent.files ?? []).map((f) => ({ path: f.path, length: f.length })),
torrentFile: torrent.torrentFile,
});
});
@@ -102,13 +109,58 @@ export class TorrentEngine {
return this.client?.torrentPort ?? null;
}
- stats(id: string): TorrentProgress | null {
+ getTorrent(id: string): Torrent | undefined {
+ return this.torrents.get(id);
+ }
+
+ fileStats(id: string): { path: string; length: number; progress: number }[] | null {
+ const t = this.torrents.get(id);
+ if (!t?.files) return null;
+ return t.files.map((f) => ({ path: f.path, length: f.length, progress: f.progress }));
+ }
+
+ applyFileSelection(id: string, selectedIndices: Set): void {
+ const t = this.torrents.get(id);
+ if (!t?.files) return;
+ for (let i = 0; i < t.files.length; i++) {
+ const file = t.files[i];
+ if (file) {
+ if (selectedIndices.has(i)) {
+ file.select();
+ } else {
+ file.deselect();
+ }
+ }
+ }
+ }
+
+ stats(id: string, selectedIndices?: Set): TorrentProgress | null {
const t = this.torrents.get(id);
if (!t) return null;
+
+ let total = t.length;
+ let downloaded = t.downloaded;
+ let progress = t.progress;
+
+ if (selectedIndices && t.files) {
+ total = 0;
+ downloaded = 0;
+ for (let i = 0; i < t.files.length; i++) {
+ if (selectedIndices.has(i)) {
+ const f = t.files[i];
+ if (f) {
+ total += f.length;
+ downloaded += Math.round(f.progress * f.length);
+ }
+ }
+ }
+ progress = total > 0 ? downloaded / total : 0;
+ }
+
return {
- progress: t.progress,
- downloaded: t.downloaded,
- total: t.length,
+ progress,
+ downloaded,
+ total,
speed: t.downloadSpeed,
uploadSpeed: t.uploadSpeed,
uploaded: t.uploaded,
diff --git a/src/download/queue.test.ts b/src/download/queue.test.ts
index f3d46d3..bb26f86 100644
--- a/src/download/queue.test.ts
+++ b/src/download/queue.test.ts
@@ -61,3 +61,44 @@ describe("strayDownload (missing-file safety-net)", () => {
expect(strayDownload({ total: 0, progress: 0, speed: 0 })).toBe(false);
});
});
+
+describe("DownloadQueue selective download", () => {
+ it("add with selectedIndices stores indices and processes selective stats on metadata", () => {
+ const q = new DownloadQueue();
+ q.add(
+ {
+ id: "sel1",
+ name: "Selective Item",
+ magnet: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111",
+ selectedIndices: [0],
+ },
+ "/downloads",
+ );
+ const items = q.getItems();
+ const item = items.find((i) => i.id === "sel1");
+ expect(item).toBeDefined();
+ expect(item?.status).toBe("downloading");
+ expect(item?.selectedIndices).toEqual([0]);
+ expect(q.activeCount).toBe(1);
+
+ // Trigger metadata handler mock call
+ const handlers = q["engineHandlers"]("sel1");
+ handlers.onMetadata?.({
+ name: "Selective Item",
+ total: 300,
+ files: 2,
+ fileList: [
+ { path: "f1.txt", length: 100 },
+ { path: "f2.txt", length: 200 },
+ ],
+ torrentFile: new Uint8Array(),
+ });
+
+ const updated = q.getItems().find((i) => i.id === "sel1");
+ expect(updated?.totalBytes).toBe(100);
+ expect(updated?.fileList?.[0]?.selected).toBe(true);
+ expect(updated?.fileList?.[1]?.selected).toBe(false);
+
+ q.suspend();
+ });
+});
diff --git a/src/download/queue.ts b/src/download/queue.ts
index 5d02d02..5123147 100644
--- a/src/download/queue.ts
+++ b/src/download/queue.ts
@@ -1,4 +1,6 @@
import { EventEmitter } from "node:events";
+import { promises as fs, existsSync } from "node:fs";
+import path from "node:path";
import { TorrentEngine, type AddHandlers } from "./engine";
import {
saveQueue,
@@ -47,7 +49,7 @@ export interface AddInput {
export class DownloadQueue extends EventEmitter {
private items = new Map();
- private engine = new TorrentEngine();
+ public readonly engine = new TorrentEngine();
private poll: ReturnType | null = null;
private history: HistoryItem[] = [];
private seeds = new Map();
@@ -76,7 +78,7 @@ export class DownloadQueue extends EventEmitter {
return this.items.has(id);
}
- add(input: AddInput, dir: string): void {
+ add(input: AddInput & { selectedIndices?: number[] }, dir: string): void {
if (this.seeds.has(input.id)) {
this.engine.remove(input.id);
this.seeds.delete(input.id);
@@ -87,7 +89,7 @@ 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, status: "downloading", error: undefined, speed: 0, selectedIndices: input.selectedIndices }
: {
id: input.id,
name: input.name,
@@ -100,6 +102,7 @@ export class DownloadQueue extends EventEmitter {
downloadedBytes: 0,
speed: 0,
peers: 0,
+ selectedIndices: input.selectedIndices,
addedAt: Date.now(),
};
this.items.set(item.id, item);
@@ -127,8 +130,24 @@ export class DownloadQueue extends EventEmitter {
const it = this.items.get(id);
if (!it) return; // the rest only matters while still downloading
if (meta.name) it.name = meta.name;
- if (meta.total) it.totalBytes = meta.total;
it.files = meta.files;
+ if (meta.fileList) {
+ it.fileList = meta.fileList.map((f, i) => ({
+ path: f.path,
+ length: f.length,
+ progress: 0,
+ selected: it.selectedIndices ? it.selectedIndices.includes(i) : true,
+ }));
+ }
+ if (it.selectedIndices) {
+ this.engine.applyFileSelection(id, new Set(it.selectedIndices));
+ it.totalBytes = it.selectedIndices.reduce(
+ (sum, i) => sum + (meta.fileList?.[i]?.length ?? 0),
+ 0
+ );
+ } else {
+ if (meta.total) it.totalBytes = meta.total;
+ }
this.changed();
void this.persist();
},
@@ -173,9 +192,28 @@ export class DownloadQueue extends EventEmitter {
};
}
+ private async cleanUnwanted(it: QueueItem): Promise {
+ if (!it.fileList || !it.selectedIndices) return;
+ const selected = new Set(it.selectedIndices);
+ for (let i = 0; i < it.fileList.length; i++) {
+ if (selected.has(i)) continue;
+ const file = it.fileList[i];
+ if (!file) continue;
+ const src = path.join(it.dir, file.path);
+ if (!existsSync(src)) continue;
+ const unwantedDir = path.join(it.dir, path.dirname(file.path), ".unwanted");
+ try {
+ await fs.mkdir(unwantedDir, { recursive: true });
+ const dest = path.join(unwantedDir, path.basename(file.path));
+ await fs.rename(src, dest);
+ } catch {}
+ }
+ }
+
private complete(it: QueueItem): void {
this.recordHistory(it);
this.items.delete(it.id);
+ void this.cleanUnwanted(it);
// Opt-out seeding: a finished download is already a complete, verified
// torrent, so keep it alive and seeding instead of tearing it down.
this.beginSeed(it);
@@ -210,9 +248,10 @@ export class DownloadQueue extends EventEmitter {
private tick(): void {
let any = false;
- for (const it of this.items.values()) {
+ for (const it of Array.from(this.items.values())) {
if (it.status !== "downloading") continue;
- const s = this.engine.stats(it.id);
+ const sel = it.selectedIndices ? new Set(it.selectedIndices) : undefined;
+ const s = this.engine.stats(it.id, sel);
if (!s) continue;
it.progress = Math.min(100, Math.round(s.progress * 100));
it.downloadedBytes = s.downloaded;
@@ -224,6 +263,23 @@ export class DownloadQueue extends EventEmitter {
? s.timeRemaining / 1000
: undefined;
if (s.name) it.name = s.name;
+
+ const fs = this.engine.fileStats(it.id);
+ if (fs && it.fileList) {
+ for (let i = 0; i < it.fileList.length; i++) {
+ const file = it.fileList[i];
+ const fileStat = fs[i];
+ if (file && fileStat) {
+ file.progress = fileStat.progress;
+ }
+ }
+ }
+
+ if (sel && it.progress >= 100) {
+ if (it.totalBytes) it.downloadedBytes = it.totalBytes;
+ this.complete(it);
+ }
+
any = true;
}
const now = Date.now();
@@ -455,7 +511,9 @@ export class DownloadQueue extends EventEmitter {
restore(items: QueueItem[]): void {
for (const raw of items) {
this.items.set(raw.id, raw);
- if (raw.status === "downloading") this.startEngine(raw);
+ if (raw.status === "downloading") {
+ this.startEngine(raw);
+ }
}
if (this.activeCount > 0) this.ensurePoll();
this.changed();
diff --git a/src/download/types.ts b/src/download/types.ts
index 4a8e664..80bff16 100644
--- a/src/download/types.ts
+++ b/src/download/types.ts
@@ -4,6 +4,13 @@ export type DownloadStatus = "downloading" | "paused" | "completed" | "failed";
export type SeedStatus = "seeding" | "paused" | "missing";
+export interface TorrentFileInfo {
+ path: string;
+ length: number;
+ progress: number;
+ selected: boolean;
+}
+
export interface SeedItem {
id: string;
name: string;
@@ -31,6 +38,8 @@ export interface QueueItem {
peers: number;
eta?: number;
files?: number;
+ fileList?: TorrentFileInfo[];
+ selectedIndices?: number[];
error?: string;
addedAt: number;
}
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index eb1e14e..15b7e03 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -214,6 +214,7 @@ export function App({
magnet: string;
source?: SourceId;
sizeBytes?: number;
+ selectedIndices?: number[];
}) => {
if (!config || !queue) return;
void fs.mkdir(config.downloadDir, { recursive: true }).catch(() => {});
diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx
index 7e13349..8545d71 100644
--- a/src/ui/components/Results.tsx
+++ b/src/ui/components/Results.tsx
@@ -28,9 +28,121 @@ function DetailRow({ label, value }: { label: string; value: ReactNode }) {
);
}
-function Detail({ r, width }: { r: TorrentResult; width: number }) {
+interface DetailProps {
+ r: TorrentResult;
+ width: number;
+ listHeight: number;
+ focused: boolean;
+ onClose: () => void;
+}
+
+function Detail({ r, width, listHeight, focused, onClose }: DetailProps) {
+ const { queue, config, startDownload, copyMagnet } = useStore();
const ss = SOURCE_STYLE[r.source];
const date = formatRelative(r.added);
+
+ const [fileList, setFileList] = useState<{ path: string; length: number }[] | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [selected, setSelected] = useState>(new Set());
+ const [cursor, setCursor] = useState(0);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ // If already in queue, check if it has fileList
+ const qItem = queue.getItems().find((it) => it.id === r.infoHash);
+ if (qItem && qItem.fileList) {
+ setFileList(qItem.fileList.map((f) => ({ path: f.path, length: f.length })));
+ setSelected(new Set(qItem.selectedIndices ?? qItem.fileList.map((_, i) => i)));
+ return;
+ }
+
+ if (r.numFiles && r.numFiles <= 1) {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ let active = true;
+ const tempId = `temp-${r.infoHash}`;
+
+ try {
+ queue.engine.add(
+ tempId,
+ r.magnet,
+ config.downloadDir,
+ {
+ onMetadata: (meta) => {
+ if (!active) return;
+ if (meta.fileList) {
+ setFileList(meta.fileList);
+ setSelected(new Set(meta.fileList.map((_, i) => i)));
+ }
+ setLoading(false);
+ queue.engine.applyFileSelection(tempId, new Set());
+ },
+ onError: (msg) => {
+ if (!active) return;
+ setError(msg);
+ setLoading(false);
+ },
+ },
+ config.trackers,
+ );
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ setLoading(false);
+ }
+
+ return () => {
+ active = false;
+ queue.engine.remove(tempId);
+ };
+ }, [r.infoHash, queue, config, r.magnet, r.numFiles]);
+
+ const showFileList = fileList && fileList.length > 1;
+
+ useInput(
+ (input, key) => {
+ if (key.escape) {
+ onClose();
+ } else if (input === "y") {
+ copyMagnet({ name: r.name, magnet: r.magnet });
+ } else if (input === "d") {
+ startDownload({
+ id: r.infoHash,
+ name: r.name,
+ magnet: r.magnet,
+ source: r.source,
+ sizeBytes: r.sizeBytes,
+ selectedIndices: showFileList ? Array.from(selected) : undefined,
+ });
+ onClose();
+ } else if (showFileList && fileList) {
+ if (key.upArrow || input === "k") {
+ setCursor((c) => Math.max(0, c - 1));
+ } else if (key.downArrow || input === "j") {
+ setCursor((c) => Math.min(fileList.length - 1, c + 1));
+ } else if (input === " ") {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(cursor)) next.delete(cursor);
+ else next.add(cursor);
+ return next;
+ });
+ } else if (input === "a") {
+ setSelected((prev) => {
+ if (prev.size === fileList.length) return new Set();
+ return new Set(fileList.map((_, i) => i));
+ });
+ } else if (input === "n") {
+ setSelected(new Set());
+ }
+ }
+ },
+ { isActive: focused },
+ );
+
const health =
r.seeders || r.leechers ? (
@@ -42,6 +154,11 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
) : (
unknown
);
+
+ const fileBoxHeight = Math.max(3, listHeight - 11);
+ const fileStart = windowStart(cursor, fileList?.length ?? 0, fileBoxHeight);
+ const visibleFiles = fileList ? fileList.slice(fileStart, fileStart + fileBoxHeight) : [];
+
return (
@@ -90,6 +207,73 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
}
/>
+
+ {loading && (
+
+
+
+ )}
+
+ {error && (
+
+ Failed to load files: {error}
+
+ )}
+
+ {showFileList && fileList && (
+
+
+ Files selection checklist:
+
+
+ {visibleFiles.map((file, i) => {
+ const globalIdx = fileStart + i;
+ const isFocused = globalIdx === cursor;
+ const isChecked = selected.has(globalIdx);
+
+ const displayPath = file.path.includes("/")
+ ? file.path.substring(file.path.indexOf("/") + 1)
+ : file.path;
+
+ return (
+
+
+ {isFocused ? ICON.pointer : ""}
+
+
+
+ {isChecked ? "[x]" : "[ ]"}
+
+
+
+
+ {truncate(cleanText(displayPath), width - 30)}
+
+
+
+ {formatBytes(file.length)}
+
+
+ );
+ })}
+
+
+
+ [space] toggle . [a] all . [n] none
+
+
+ Selected: {selected.size} / {fileList.length} files ({formatBytes(Array.from(selected).reduce((sum, idx) => sum + (fileList[idx]?.length ?? 0), 0))})
+
+
+
+ )}
+
d
@@ -205,16 +389,7 @@ export function Results() {
{ isActive: focused && mode === "list" },
);
- useInput(
- (input, key) => {
- if (key.escape) {
- setMode("list");
- setDetail(null);
- } else if (input === "d" && detail) openDownload(detail);
- else if (input === "y" && detail) copyResultMagnet(detail);
- },
- { isActive: focused && mode === "detail" },
- );
+
useInput(
(_input, key) => {
@@ -327,7 +502,16 @@ export function Results() {
height={panelOuter}
>
{mode === "detail" && detail ? (
-
+ {
+ setMode("list");
+ setDetail(null);
+ }}
+ />
) : (
<>
{status()}
diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts
index 325d599..da2d787 100644
--- a/src/ui/keymap.ts
+++ b/src/ui/keymap.ts
@@ -31,6 +31,9 @@ export const HELP_GROUPS: HelpGroup[] = [
{ keys: "s", label: "Sort results" },
{ keys: "y", label: "Copy magnet" },
{ keys: "m", label: "Paste magnet" },
+ { keys: "space", label: "Toggle file selection" },
+ { keys: "a", label: "Select all files" },
+ { keys: "n", label: "Deselect all files" },
],
},
{
diff --git a/src/ui/store.ts b/src/ui/store.ts
index 0a6f4ab..d2f705e 100644
--- a/src/ui/store.ts
+++ b/src/ui/store.ts
@@ -55,6 +55,7 @@ export interface Store {
magnet: string;
source?: SourceId;
sizeBytes?: number;
+ selectedIndices?: number[];
}) => void;
copyMagnet: (input: { name: string; magnet: string }) => void;
diff --git a/src/webtorrent.d.ts b/src/webtorrent.d.ts
index 568e3e9..030feaf 100644
--- a/src/webtorrent.d.ts
+++ b/src/webtorrent.d.ts
@@ -5,6 +5,10 @@ declare module "webtorrent" {
name: string;
path: string;
length: number;
+ offset: number;
+ progress: number;
+ select(priority?: number): void;
+ deselect(): void;
}
interface Torrent extends EventEmitter {