torrent-tui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/bin/torrent-tui +26 -0
  4. package/package.json +58 -0
  5. package/src/app.ts +157 -0
  6. package/src/config/index.ts +41 -0
  7. package/src/config/settings.ts +15 -0
  8. package/src/constants/index.ts +16 -0
  9. package/src/controllers/app-controller.ts +180 -0
  10. package/src/index.ts +320 -0
  11. package/src/layout/add-torrent-dialog.ts +170 -0
  12. package/src/layout/confirm-dialog.ts +141 -0
  13. package/src/layout/content-window.ts +80 -0
  14. package/src/layout/sidebar.ts +121 -0
  15. package/src/layout/status-bar.ts +79 -0
  16. package/src/layout/toast-manager.ts +109 -0
  17. package/src/layout/toast.ts +257 -0
  18. package/src/layout/torrent-view.ts +250 -0
  19. package/src/store/index.ts +51 -0
  20. package/src/theme/default.ts +22 -0
  21. package/src/theme/index.ts +19 -0
  22. package/src/theme/types.ts +26 -0
  23. package/src/torrent/bridge.ts +301 -0
  24. package/src/torrent/downloader.ts +415 -0
  25. package/src/torrent/get_peers.ts +212 -0
  26. package/src/torrent/metadata.ts +190 -0
  27. package/src/torrent/parser.ts +216 -0
  28. package/src/torrent/peer/connection.ts +278 -0
  29. package/src/torrent/peer/handshake.ts +48 -0
  30. package/src/torrent/peer/listener.ts +52 -0
  31. package/src/torrent/peer/manager.ts +233 -0
  32. package/src/torrent/peer/message-buffer.ts +31 -0
  33. package/src/torrent/peer/peer-id.ts +21 -0
  34. package/src/torrent/peer/protocol.ts +123 -0
  35. package/src/torrent/piece-picker.ts +58 -0
  36. package/src/torrent/session.ts +56 -0
  37. package/src/torrent/storage.ts +197 -0
  38. package/src/torrent/tracker/announce.ts +36 -0
  39. package/src/torrent/tracker/http-tracker.ts +143 -0
  40. package/src/torrent/tracker/udp-tracker.ts +136 -0
  41. package/src/torrent/types.ts +25 -0
  42. package/src/types/layout.ts +6 -0
  43. package/src/utils/env.ts +8 -0
  44. package/src/utils/filter.ts +12 -0
  45. package/src/utils/layout.ts +32 -0
  46. package/src/utils/paths.ts +17 -0
package/src/index.ts ADDED
@@ -0,0 +1,320 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { VERSION } from "./constants";
4
+ import { getPeers } from "./torrent/get_peers";
5
+
6
+ class CliExit extends Error {}
7
+
8
+ function fail(msg: string): never {
9
+ console.error(msg);
10
+ process.exitCode = 1;
11
+ throw new CliExit();
12
+ }
13
+
14
+ function printHelp(): void {
15
+ console.log(`torrent-tui ${VERSION}
16
+
17
+ Usage:
18
+ torrent-tui Start the terminal UI
19
+ torrent-tui <file.torrent> Announce and print peers
20
+ torrent-tui <file.torrent> --verify Verify local pieces and trackers
21
+ torrent-tui <file.torrent> --handshake
22
+ Connect to peers and print handshake summary
23
+ torrent-tui <file.torrent> --download
24
+ Download from the command line
25
+
26
+ Options:
27
+ --help, -h Show this help
28
+ --version, -v Print the version`);
29
+ }
30
+
31
+ function validateTorrentArg(arg: string): string {
32
+ if (!arg.toLowerCase().endsWith(".torrent")) {
33
+ fail(`Error: '${arg}' is not a .torrent file`);
34
+ }
35
+ if (!existsSync(arg)) {
36
+ fail(`Error: File not found: '${arg}'`);
37
+ }
38
+ return arg;
39
+ }
40
+
41
+ function sep(): void {
42
+ console.log("-".repeat(44));
43
+ }
44
+
45
+ async function loadTorrent(torrentPath: string) {
46
+ const { decode } = await import("./torrent/parser");
47
+ const { TorrentMetadata } = await import("./torrent/metadata");
48
+ const { TorrentSession } = await import("./torrent/session");
49
+ const { loadConfig } = await import("./config/index");
50
+
51
+ const config = loadConfig();
52
+ const { resolvePath } = await import("./utils/paths");
53
+ const downloadPath = resolvePath(config.downloadPath);
54
+ const raw = new Uint8Array(readFileSync(torrentPath));
55
+ const decoded = decode(raw);
56
+
57
+ if (
58
+ typeof decoded !== "object" ||
59
+ decoded === null ||
60
+ Array.isArray(decoded) ||
61
+ decoded instanceof Uint8Array
62
+ ) {
63
+ fail("Invalid torrent file");
64
+ }
65
+
66
+ const metadata = new TorrentMetadata(
67
+ decoded as { [key: string]: import("./torrent/parser").BencodeValue },
68
+ raw,
69
+ );
70
+ const session = new TorrentSession(metadata, downloadPath);
71
+ return { metadata, session, downloadPath };
72
+ }
73
+
74
+ async function runVerify(torrentPath: string): Promise<void> {
75
+ const { log } = await import("./torrent/metadata");
76
+ const { announce } = await import("./torrent/tracker/announce");
77
+ const { metadata, session, downloadPath } = await loadTorrent(torrentPath);
78
+
79
+ metadata.logSummary();
80
+ await session.start();
81
+
82
+ const trackerResult = await announce(metadata).catch(() => null);
83
+ const peers = trackerResult?.peers ?? [];
84
+
85
+ let valid = 0;
86
+ let missing = 0;
87
+ const corrupt = 0;
88
+ for (let i = 0; i < metadata.pieceCount; i++) {
89
+ if (session.storage.hasPiece(i)) valid++;
90
+ else missing++;
91
+ }
92
+
93
+ console.log("");
94
+ sep();
95
+ console.log(" Verify Summary");
96
+ sep();
97
+ log(
98
+ "storage",
99
+ `${metadata.files.map((f) => join(downloadPath, f.path)).join(", ")}`,
100
+ );
101
+ log(
102
+ "verify",
103
+ `${valid} valid ${missing} missing ${corrupt} corrupt (${metadata.pieceCount} total)`,
104
+ );
105
+ log("tracker", `${peers.length} peers`);
106
+ console.log("");
107
+ for (const file of metadata.files) {
108
+ const fullPath = join(downloadPath, file.path);
109
+ console.log(` ${existsSync(fullPath) ? "✓" : "✗"} ${fullPath}`);
110
+ }
111
+ sep();
112
+ }
113
+
114
+ async function runHandshake(torrentPath: string): Promise<void> {
115
+ const { announce } = await import("./torrent/tracker/announce");
116
+ const { PeerManager } = await import("./torrent/peer/manager");
117
+ const { getPeerId, peerIdToString } = await import("./torrent/peer/peer-id");
118
+ const { metadata, session } = await loadTorrent(torrentPath);
119
+
120
+ metadata.logSummary();
121
+ const { log } = await import("./torrent/metadata");
122
+ log("peer-id", peerIdToString(getPeerId()));
123
+
124
+ // session.start() internally logs storage + verify
125
+ await session.start();
126
+
127
+ // announce() internally logs tracker result
128
+ const trackerResult = await announce(metadata).catch(() => null);
129
+ const peers = trackerResult?.peers ?? [];
130
+
131
+ console.log("");
132
+
133
+ // manager.start() internally logs the listener port
134
+ const manager = new PeerManager(metadata);
135
+ await manager.start();
136
+
137
+ // connect() logs one "handshake" line per peer (success or timeout)
138
+ await manager.connect(peers);
139
+
140
+ // Wait up to 15s for bitfields and unchokes to arrive
141
+ await new Promise((r) => setTimeout(r, 15_000));
142
+
143
+ const connected = [...manager.connections.values()];
144
+ const unchoked = connected.filter((c) => !c.amChoked);
145
+ const failed = peers.length - connected.length;
146
+
147
+ // Summary
148
+ const W = 80;
149
+ const line = "-".repeat(W);
150
+ console.log(`\n${line}`);
151
+ console.log(" Connection Summary");
152
+ console.log(line);
153
+ console.log(` attempted ${peers.length}`);
154
+ console.log(
155
+ ` connected ${connected.length} unchoked ${unchoked.length} failed ${failed}`,
156
+ );
157
+
158
+ if (connected.length > 0) {
159
+ const AW = 46; // address column width (fits longest IPv6+port)
160
+ const CW = 10; // client ID column
161
+ const PW = 13; // pieces column
162
+ console.log("");
163
+ console.log(
164
+ ` ${"address".padEnd(AW)} ${"client".padEnd(CW)} ${"pieces".padEnd(PW)} choked`,
165
+ );
166
+ console.log(` ${"-".repeat(W - 2)}`);
167
+ for (const c of connected) {
168
+ const addr = `${c.address}:${c.port}`.padEnd(AW);
169
+ const client = c.peerId.slice(0, 8).padEnd(CW);
170
+ const pieces = `${c.countPiecesPublic()}/${metadata.pieceCount}`.padEnd(
171
+ PW,
172
+ );
173
+ const choked = c.amChoked ? "yes" : "no";
174
+ console.log(` ${addr} ${client} ${pieces} ${choked}`);
175
+ }
176
+ }
177
+ console.log(line);
178
+
179
+ manager.close();
180
+ }
181
+
182
+ async function runDownload(torrentPath: string): Promise<void> {
183
+ const { announce } = await import("./torrent/tracker/announce");
184
+ const { PeerManager } = await import("./torrent/peer/manager");
185
+ const { getPeerId, peerIdToString } = await import("./torrent/peer/peer-id");
186
+ const { metadata, session } = await loadTorrent(torrentPath);
187
+ const { log } = await import("./torrent/metadata");
188
+
189
+ metadata.logSummary();
190
+ log("peer-id", peerIdToString(getPeerId()));
191
+
192
+ await session.start();
193
+
194
+ const trackerResult = await announce(metadata).catch(() => null);
195
+ const peers = trackerResult?.peers ?? [];
196
+
197
+ console.log("");
198
+ const manager = new PeerManager(metadata);
199
+ await manager.start();
200
+
201
+ // connect() now resolves only after each handshake completes —
202
+ // so all handshake logs finish before the progress bar starts
203
+ await manager.connect(peers);
204
+
205
+ if (manager.connections.size === 0) {
206
+ log("error", "no peers connected — cannot download");
207
+ manager.close();
208
+ return;
209
+ }
210
+
211
+ const unchoked = manager.getUnchoked().length;
212
+ log("peers", `${manager.connections.size} connected ${unchoked} unchoked`);
213
+
214
+ manager.startChoking();
215
+ const downloader = session.download(manager);
216
+ log("log file", downloader.getLogFilePath());
217
+ console.log("");
218
+
219
+ await new Promise<void>((resolve) => {
220
+ session.on("complete", () => resolve());
221
+ process.on("SIGINT", () => {
222
+ downloader.stop();
223
+ resolve();
224
+ });
225
+ });
226
+
227
+ const downloaded = session.storage.downloadedCount;
228
+ const W = 80;
229
+ const line = "-".repeat(W);
230
+
231
+ console.log(`\n${line}`);
232
+ console.log(" Download Summary");
233
+ console.log(line);
234
+ console.log(` torrent ${metadata.name}`);
235
+ console.log(
236
+ ` pieces ${downloaded} / ${metadata.pieceCount} downloaded`,
237
+ );
238
+ console.log(` status ${session.status}`);
239
+
240
+ if (downloaded > 0) {
241
+ const resumeDir = join(
242
+ (await import("./utils/paths")).getDataDir(),
243
+ "resume",
244
+ );
245
+ const hex = Buffer.from(metadata.infoHash).toString("hex");
246
+ console.log(` resume ${resumeDir}/${hex}.json`);
247
+ }
248
+
249
+ console.log("");
250
+ console.log(" files");
251
+ for (const file of metadata.files) {
252
+ const fullPath = join(session.downloadPath, file.path);
253
+ console.log(` ${existsSync(fullPath) ? "✓" : "✗"} ${fullPath}`);
254
+ }
255
+ console.log(line);
256
+
257
+ manager.close();
258
+ }
259
+
260
+ async function main() {
261
+ const args = process.argv.slice(2);
262
+
263
+ if (args.includes("--help") || args.includes("-h")) {
264
+ printHelp();
265
+ return;
266
+ }
267
+
268
+ if (args.includes("--version") || args.includes("-v")) {
269
+ console.log(VERSION);
270
+ return;
271
+ }
272
+
273
+ const torrentArg = args.find((a) => !a.startsWith("--"));
274
+ const isVerify = args.includes("--verify");
275
+ const isHandshake = args.includes("--handshake");
276
+ const isDownload = args.includes("--download");
277
+
278
+ if (torrentArg) {
279
+ const torrentPath = validateTorrentArg(torrentArg);
280
+
281
+ if (isVerify) {
282
+ await runVerify(torrentPath).catch((e) =>
283
+ fail(`Error: ${e instanceof Error ? e.message : e}`),
284
+ );
285
+ return;
286
+ }
287
+
288
+ if (isHandshake) {
289
+ await runHandshake(torrentPath).catch((e) =>
290
+ fail(`Error: ${e instanceof Error ? e.message : e}`),
291
+ );
292
+ return;
293
+ }
294
+
295
+ if (isDownload) {
296
+ await runDownload(torrentPath).catch((e) =>
297
+ fail(`Error: ${e instanceof Error ? e.message : e}`),
298
+ );
299
+ return;
300
+ }
301
+
302
+ try {
303
+ await getPeers(torrentPath, 6881, 50);
304
+ } catch (e) {
305
+ fail(`Error: ${e instanceof Error ? e.message : e}`);
306
+ }
307
+
308
+ return;
309
+ }
310
+
311
+ const { App } = await import("./app");
312
+ const app = new App();
313
+ app.start();
314
+ }
315
+
316
+ main().catch((err: unknown) => {
317
+ if (err instanceof CliExit) return;
318
+ process.exitCode = 1;
319
+ console.error(`Error: ${err instanceof Error ? err.message : err}`);
320
+ });
@@ -0,0 +1,170 @@
1
+ import { readdirSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { BoxRenderable, type CliRenderer, TextRenderable } from "@opentui/core";
4
+ import { getTheme } from "../theme";
5
+ import type { LayoutDimensions } from "../types/layout";
6
+ import { resolvePath } from "../utils/paths";
7
+
8
+ const DIALOG_WIDTH = 60;
9
+ const DIALOG_HEIGHT = 16;
10
+ const INNER_W = DIALOG_WIDTH - 2;
11
+ const MARGIN = 2;
12
+
13
+ function truncateName(name: string): string {
14
+ const max = INNER_W - MARGIN * 2;
15
+ return name.length > max ? name.slice(0, max - 1) + "…" : name;
16
+ }
17
+
18
+ function setBg(node: BoxRenderable, bg: string | undefined): void {
19
+ (node as unknown as { backgroundColor: string | undefined }).backgroundColor = bg;
20
+ }
21
+
22
+ export class AddTorrentDialog {
23
+ private renderer: CliRenderer;
24
+ private layout: LayoutDimensions;
25
+ private torrentFolder: string;
26
+ private container: BoxRenderable | null = null;
27
+ private isOpen = false;
28
+ private files: Array<{ name: string; path: string }> = [];
29
+ private selectedIndex = 0;
30
+ private itemRows: BoxRenderable[] = [];
31
+
32
+ onSelect?: (filePath: string) => void;
33
+
34
+ constructor(renderer: CliRenderer, layout: LayoutDimensions, torrentFolder: string) {
35
+ this.renderer = renderer;
36
+ this.layout = layout;
37
+ this.torrentFolder = resolvePath(torrentFolder);
38
+ }
39
+
40
+ open(): void {
41
+ if (this.isOpen) return;
42
+ this.isOpen = true;
43
+ this.selectedIndex = 0;
44
+ this.files = this.scanTorrentFiles();
45
+ this.build();
46
+ }
47
+
48
+ close(): void {
49
+ if (!this.isOpen) return;
50
+ this.isOpen = false;
51
+ this.container?.destroy();
52
+ this.container = null;
53
+ this.itemRows = [];
54
+ }
55
+
56
+ getIsOpen(): boolean {
57
+ return this.isOpen;
58
+ }
59
+
60
+ handleInput(key: string): boolean {
61
+ if (!this.isOpen) return false;
62
+
63
+ if (key === "j" || key === "down") {
64
+ if (this.selectedIndex < this.files.length - 1) {
65
+ this.selectedIndex++;
66
+ this.updateHighlight();
67
+ }
68
+ return true;
69
+ }
70
+ if (key === "k" || key === "up") {
71
+ if (this.selectedIndex > 0) {
72
+ this.selectedIndex--;
73
+ this.updateHighlight();
74
+ }
75
+ return true;
76
+ }
77
+ if (key === "return") {
78
+ const file = this.files[this.selectedIndex];
79
+ if (file) {
80
+ const path = file.path;
81
+ this.close();
82
+ this.onSelect?.(path);
83
+ }
84
+ return true;
85
+ }
86
+ return false;
87
+ }
88
+
89
+ updateLayout(layout: LayoutDimensions): void {
90
+ this.layout = layout;
91
+ }
92
+
93
+ private updateHighlight(): void {
94
+ const theme = getTheme();
95
+ for (let i = 0; i < this.itemRows.length; i++) {
96
+ setBg(this.itemRows[i]!, i === this.selectedIndex ? theme.bgTertiary : undefined);
97
+ }
98
+ }
99
+
100
+ private build(): void {
101
+ const theme = getTheme();
102
+ const left = Math.max(0, Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2));
103
+ const top = Math.max(0, Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2));
104
+
105
+ const container = new BoxRenderable(this.renderer, {
106
+ position: "absolute",
107
+ left,
108
+ top,
109
+ width: DIALOG_WIDTH,
110
+ height: DIALOG_HEIGHT,
111
+ border: true,
112
+ borderColor: theme.border,
113
+ flexDirection: "column",
114
+ });
115
+
116
+ // Title row
117
+ const titleRow = new BoxRenderable(this.renderer, {
118
+ width: INNER_W,
119
+ height: 1,
120
+ flexDirection: "row",
121
+ justifyContent: "space-between",
122
+ paddingLeft: MARGIN,
123
+ paddingRight: MARGIN,
124
+ });
125
+ titleRow.add(new TextRenderable(this.renderer, { content: "Add Torrent", fg: theme.accent }));
126
+ titleRow.add(new TextRenderable(this.renderer, { content: "Esc to close", fg: theme.fgMuted }));
127
+ container.add(titleRow);
128
+
129
+ // Spacer
130
+ container.add(new TextRenderable(this.renderer, { content: "" }));
131
+
132
+ this.itemRows = [];
133
+
134
+ if (this.files.length === 0) {
135
+ container.add(new TextRenderable(this.renderer, {
136
+ content: " ".repeat(MARGIN) + `No .torrent files in ${this.torrentFolder}`,
137
+ fg: theme.fgMuted,
138
+ }));
139
+ } else {
140
+ for (let i = 0; i < this.files.length; i++) {
141
+ const file = this.files[i]!;
142
+ const row = new BoxRenderable(this.renderer, {
143
+ width: INNER_W,
144
+ height: 1,
145
+ backgroundColor: i === this.selectedIndex ? theme.bgTertiary : undefined,
146
+ });
147
+ row.add(new TextRenderable(this.renderer, {
148
+ content: " ".repeat(MARGIN) + truncateName(file.name),
149
+ fg: i === this.selectedIndex ? theme.fgPrimary : theme.fgSecondary,
150
+ }));
151
+ container.add(row);
152
+ this.itemRows.push(row);
153
+ }
154
+ }
155
+
156
+ this.renderer.root.add(container);
157
+ this.container = container;
158
+ }
159
+
160
+ private scanTorrentFiles(): Array<{ name: string; path: string }> {
161
+ if (!existsSync(this.torrentFolder)) return [];
162
+ try {
163
+ return readdirSync(this.torrentFolder)
164
+ .filter((f) => f.toLowerCase().endsWith(".torrent"))
165
+ .map((f) => ({ name: f, path: join(this.torrentFolder, f) }));
166
+ } catch {
167
+ return [];
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,141 @@
1
+ import { BoxRenderable, type CliRenderer, TextRenderable } from "@opentui/core";
2
+ import { getTheme } from "../theme";
3
+ import type { LayoutDimensions } from "../types/layout";
4
+
5
+ const DIALOG_WIDTH = 38;
6
+ const DIALOG_HEIGHT = 7;
7
+
8
+ export class ConfirmDialog {
9
+ private renderer: CliRenderer;
10
+ private layout: LayoutDimensions;
11
+ private container: BoxRenderable | null = null;
12
+ private confirmBtn: TextRenderable | null = null;
13
+ private cancelBtn: TextRenderable | null = null;
14
+ private isOpen = false;
15
+ private focusedBtn: "confirm" | "cancel" = "cancel";
16
+
17
+ onConfirm?: () => void;
18
+ onCancel?: () => void;
19
+
20
+ constructor(renderer: CliRenderer, layout: LayoutDimensions) {
21
+ this.renderer = renderer;
22
+ this.layout = layout;
23
+ }
24
+
25
+ open(message: string): void {
26
+ if (this.isOpen) return;
27
+ this.isOpen = true;
28
+ this.focusedBtn = "cancel";
29
+ this.build(message);
30
+ }
31
+
32
+ close(): void {
33
+ if (!this.isOpen) return;
34
+ this.isOpen = false;
35
+ this.container?.destroy();
36
+ this.container = null;
37
+ this.confirmBtn = null;
38
+ this.cancelBtn = null;
39
+ }
40
+
41
+ getIsOpen(): boolean {
42
+ return this.isOpen;
43
+ }
44
+
45
+ handleInput(key: string): boolean {
46
+ if (!this.isOpen) return false;
47
+ if (key === "tab" || key === "h" || key === "l" || key === "left" || key === "right") {
48
+ this.focusedBtn = this.focusedBtn === "confirm" ? "cancel" : "confirm";
49
+ this.updateButtons();
50
+ return true;
51
+ }
52
+ if (key === "return" || key === "y") {
53
+ if (key === "y" || this.focusedBtn === "confirm") {
54
+ this.close();
55
+ this.onConfirm?.();
56
+ return true;
57
+ }
58
+ this.close();
59
+ this.onCancel?.();
60
+ return true;
61
+ }
62
+ if (key === "n" || key === "escape") {
63
+ this.close();
64
+ this.onCancel?.();
65
+ return true;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ updateLayout(layout: LayoutDimensions): void {
71
+ this.layout = layout;
72
+ }
73
+
74
+ private updateButtons(): void {
75
+ const theme = getTheme();
76
+ if (this.confirmBtn) {
77
+ (this.confirmBtn as unknown as { fg: string }).fg =
78
+ this.focusedBtn === "confirm" ? theme.error : theme.fgMuted;
79
+ }
80
+ if (this.cancelBtn) {
81
+ (this.cancelBtn as unknown as { fg: string }).fg =
82
+ this.focusedBtn === "cancel" ? theme.accent : theme.fgMuted;
83
+ }
84
+ }
85
+
86
+ private build(message: string): void {
87
+ const theme = getTheme();
88
+ const left = Math.max(0, Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2));
89
+ const top = Math.max(0, Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2));
90
+
91
+ const container = new BoxRenderable(this.renderer, {
92
+ position: "absolute",
93
+ left,
94
+ top,
95
+ width: DIALOG_WIDTH,
96
+ height: DIALOG_HEIGHT,
97
+ border: true,
98
+ borderColor: theme.warning,
99
+ flexDirection: "column",
100
+ });
101
+
102
+ const inner = DIALOG_WIDTH - 2;
103
+
104
+ container.add(new TextRenderable(this.renderer, { content: " ".repeat(inner) }));
105
+ container.add(new TextRenderable(this.renderer, {
106
+ content: (" " + message).padEnd(inner),
107
+ fg: theme.fgPrimary,
108
+ }));
109
+ container.add(new TextRenderable(this.renderer, { content: " ".repeat(inner) }));
110
+ container.add(new TextRenderable(this.renderer, {
111
+ content: " Files will be deleted from disk.".padEnd(inner),
112
+ fg: theme.fgSecondary,
113
+ }));
114
+ container.add(new TextRenderable(this.renderer, { content: " ".repeat(inner) }));
115
+
116
+ const btnRow = new BoxRenderable(this.renderer, {
117
+ width: inner,
118
+ height: 1,
119
+ flexDirection: "row",
120
+ justifyContent: "space-between",
121
+ paddingLeft: 2,
122
+ paddingRight: 2,
123
+ });
124
+
125
+ this.confirmBtn = new TextRenderable(this.renderer, {
126
+ content: "[y] Confirm",
127
+ fg: theme.fgMuted,
128
+ });
129
+ this.cancelBtn = new TextRenderable(this.renderer, {
130
+ content: "[n] Cancel",
131
+ fg: theme.accent,
132
+ });
133
+
134
+ btnRow.add(this.confirmBtn);
135
+ btnRow.add(this.cancelBtn);
136
+ container.add(btnRow);
137
+
138
+ this.renderer.root.add(container);
139
+ this.container = container;
140
+ }
141
+ }
@@ -0,0 +1,80 @@
1
+ import { BoxRenderable, type CliRenderer } from "@opentui/core";
2
+ import type { Store } from "../store";
3
+ import { getTheme } from "../theme";
4
+ import type { LayoutDimensions } from "../types/layout";
5
+ import { filterTorrents } from "../utils/filter";
6
+ import { TorrentTable } from "./torrent-view";
7
+
8
+ function innerLayout(layout: LayoutDimensions): LayoutDimensions {
9
+ return {
10
+ ...layout,
11
+ content: {
12
+ ...layout.content,
13
+ width: layout.content.width - 2,
14
+ height: layout.content.height - 2,
15
+ },
16
+ };
17
+ }
18
+
19
+ export class ContentWindow {
20
+ private renderer: CliRenderer;
21
+ private store: Store;
22
+ private layout: LayoutDimensions;
23
+ private container: BoxRenderable;
24
+ private torrentTable: TorrentTable;
25
+
26
+ constructor(renderer: CliRenderer, store: Store, layout: LayoutDimensions) {
27
+ this.renderer = renderer;
28
+ this.store = store;
29
+ this.layout = layout;
30
+ this.torrentTable = new TorrentTable(renderer, innerLayout(layout));
31
+ this.container = this.build();
32
+ this.renderer.root.add(this.container);
33
+ }
34
+
35
+ update(focusArea: "sidebar" | "table", selectedIndex: number): void {
36
+ const theme = getTheme();
37
+ const state = this.store.getState();
38
+ (this.container as unknown as { borderColor: string }).borderColor =
39
+ focusArea === "table" ? theme.accent : theme.border;
40
+ const visible = filterTorrents(state.torrents, state.selectedView);
41
+ this.torrentTable.update(visible, selectedIndex, focusArea);
42
+ }
43
+
44
+ updateLayout(layout: LayoutDimensions): void {
45
+ this.layout = layout;
46
+ (this.container as unknown as { left: number }).left = layout.content.x;
47
+ (this.container as unknown as { top: number }).top = layout.content.y;
48
+ (this.container as unknown as { width: number }).width = layout.content.width;
49
+ (this.container as unknown as { height: number }).height = layout.content.height;
50
+ this.torrentTable.updateLayout(innerLayout(layout));
51
+ }
52
+
53
+ private build(): BoxRenderable {
54
+ const theme = getTheme();
55
+ const layout = this.layout;
56
+
57
+ const container = new BoxRenderable(this.renderer, {
58
+ position: "absolute",
59
+ left: layout.content.x,
60
+ top: layout.content.y,
61
+ width: layout.content.width,
62
+ height: layout.content.height,
63
+ border: true,
64
+ borderColor: theme.border,
65
+ });
66
+
67
+ // Inner wrapper offset by 1 on each side to sit inside the border
68
+ const inner = new BoxRenderable(this.renderer, {
69
+ position: "absolute",
70
+ left: 1,
71
+ top: 1,
72
+ width: layout.content.width - 2,
73
+ height: layout.content.height - 2,
74
+ });
75
+ inner.add(this.torrentTable.getContainer());
76
+ container.add(inner);
77
+
78
+ return container;
79
+ }
80
+ }