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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aditya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # torrent-tui
2
+
3
+ **A Bun-powered terminal BitTorrent client.** Add `.torrent` files, manage active downloads, and inspect transfer state from a focused TUI.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/torrent-tui?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/torrent-tui)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/ryadios/torrent-tui/ci.yml?branch=main&style=for-the-badge&logo=github)](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
7
+ [![license](https://img.shields.io/npm/l/torrent-tui?style=for-the-badge)](./LICENSE)
8
+
9
+ [Install](#install) · [Quickstart](#quickstart) · [Commands](#commands) · [Configuration](#configuration) · [Development](#development)
10
+
11
+ > [!NOTE]
12
+ > `torrent-tui` currently requires [Bun](https://bun.sh). Standalone binaries are planned after the npm CLI release path is stable.
13
+
14
+ > [!IMPORTANT]
15
+ > Use torrent clients only with content you have the right to download or share. `torrent-tui` is a client implementation, not a content source.
16
+
17
+ ## Install
18
+
19
+ Run without installing:
20
+
21
+ ```bash
22
+ bunx torrent-tui@latest
23
+ ```
24
+
25
+ Or install globally:
26
+
27
+ ```bash
28
+ bun add -g torrent-tui
29
+ torrent-tui
30
+ ```
31
+
32
+ > [!TIP]
33
+ > If the global command is not found, add Bun's global bin directory to your shell path:
34
+ >
35
+ > ```bash
36
+ > export PATH="$HOME/.bun/bin:$HOME/.cache/.bun/bin:$PATH"
37
+ > ```
38
+
39
+ ## Quickstart
40
+
41
+ Start the TUI:
42
+
43
+ ```bash
44
+ torrent-tui
45
+ ```
46
+
47
+ From inside the app:
48
+
49
+ | Key | Action |
50
+ | --- | --- |
51
+ | `j` / `k` or arrow keys | Move selection |
52
+ | `Tab` | Change focus |
53
+ | `a` | Add a `.torrent` file |
54
+ | `Space` | Pause or resume the selected torrent |
55
+ | `d` | Remove the selected torrent |
56
+ | `D` | Remove the selected torrent and downloaded files |
57
+ | `q` | Quit |
58
+
59
+ ## Commands
60
+
61
+ The package also exposes a few command-line checks around the same torrent engine:
62
+
63
+ ```bash
64
+ torrent-tui --help
65
+ torrent-tui --version
66
+ torrent-tui file.torrent --verify
67
+ torrent-tui file.torrent --handshake
68
+ torrent-tui file.torrent --download
69
+ ```
70
+
71
+ | Command | Description |
72
+ | --- | --- |
73
+ | `torrent-tui` | Start the terminal UI. |
74
+ | `torrent-tui <file.torrent>` | Announce to trackers and print peers. |
75
+ | `torrent-tui <file.torrent> --verify` | Create storage and verify local pieces. |
76
+ | `torrent-tui <file.torrent> --handshake` | Connect to peers and print a connection summary. |
77
+ | `torrent-tui <file.torrent> --download` | Run the downloader without launching the TUI. |
78
+
79
+ ## Configuration
80
+
81
+ Settings are stored at:
82
+
83
+ ```text
84
+ ${XDG_CONFIG_HOME:-~/.config}/torrent-tui/settings.json
85
+ ```
86
+
87
+ Default settings:
88
+
89
+ ```json
90
+ {
91
+ "downloadPath": "~/Downloads",
92
+ "maxConnections": 50,
93
+ "torrentFolder": "~/Downloads"
94
+ }
95
+ ```
96
+
97
+ Resume data is stored under:
98
+
99
+ ```text
100
+ ${XDG_DATA_HOME:-~/.local/share}/torrent-tui/resume
101
+ ```
102
+
103
+ ## Status
104
+
105
+ `0.0.1` is a basic release intended for early CLI usage.
106
+
107
+ | Area | Status |
108
+ | --- | --- |
109
+ | `.torrent` metadata parsing | Available |
110
+ | HTTP and UDP trackers | Available |
111
+ | Peer handshakes and piece download | Available |
112
+ | Resume data | Available |
113
+ | Multi-torrent TUI | Available |
114
+ | Magnet links | Not included yet |
115
+ | Standalone binaries | Not included yet |
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ bun install
121
+ bun run dev
122
+ ```
123
+
124
+ Before opening a PR:
125
+
126
+ ```bash
127
+ bun run typecheck
128
+ bun publish --dry-run
129
+ ```
130
+
131
+ For formatting and lint fixes:
132
+
133
+ ```bash
134
+ bun run check:fix
135
+ ```
136
+
137
+ ## Release Flow
138
+
139
+ Releases are published from GitHub Actions with generated GitHub release notes.
140
+
141
+ 1. Update `package.json` and `src/constants/index.ts` to the new version.
142
+ 2. Run local checks:
143
+
144
+ ```bash
145
+ bun run typecheck
146
+ bun publish --dry-run
147
+ ```
148
+
149
+ 3. Commit and push the version change.
150
+ 4. Run the **Release** workflow manually with the version number.
151
+
152
+ The workflow publishes to npm with trusted publishing, creates `vX.Y.Z`, and creates a GitHub release with `--generate-notes`.
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const binDir = dirname(fileURLToPath(import.meta.url));
8
+ const entrypoint = join(binDir, "../src/index.ts");
9
+ const bun = process.platform === "win32" ? "bun.exe" : "bun";
10
+
11
+ const result = spawnSync(bun, [entrypoint, ...process.argv.slice(2)], {
12
+ stdio: "inherit",
13
+ });
14
+
15
+ if (result.error) {
16
+ if ("code" in result.error && result.error.code === "ENOENT") {
17
+ console.error("torrent-tui requires Bun. Install it from https://bun.sh");
18
+ } else {
19
+ console.error(result.error.message);
20
+ }
21
+ process.exitCode = 1;
22
+ } else if (result.signal) {
23
+ process.kill(process.pid, result.signal);
24
+ } else {
25
+ process.exitCode = result.status ?? 0;
26
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "torrent-tui",
3
+ "version": "0.0.1",
4
+ "description": "A Bun-powered terminal BitTorrent client.",
5
+ "module": "src/index.ts",
6
+ "type": "module",
7
+ "private": false,
8
+ "packageManager": "bun@1.2.21",
9
+ "bin": {
10
+ "torrent-tui": "bin/torrent-tui"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "start": "bun src/index.ts",
20
+ "dev": "bun --watch src/index.ts",
21
+ "check": "bun src/index.ts --download",
22
+ "smoke": "bin/torrent-tui --help && bin/torrent-tui --version && if bin/torrent-tui nope.txt; then echo \"Expected invalid torrent path to fail\"; exit 1; fi",
23
+ "typecheck": "tsc --noEmit",
24
+ "check:fix": "biome check --write --unsafe",
25
+ "release:check": "bun run typecheck && bun publish --dry-run"
26
+ },
27
+ "keywords": [
28
+ "bittorrent",
29
+ "torrent",
30
+ "tui",
31
+ "terminal",
32
+ "cli",
33
+ "bun",
34
+ "opentui"
35
+ ],
36
+ "author": "Aditya",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/ryadios/torrent-tui.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/ryadios/torrent-tui/issues"
44
+ },
45
+ "homepage": "https://github.com/ryadios/torrent-tui#readme",
46
+ "engines": {
47
+ "bun": ">=1.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@biomejs/biome": "2.4.8",
51
+ "@types/bun": "latest",
52
+ "typescript": "^5"
53
+ },
54
+ "dependencies": {
55
+ "@opentui/core": "^0.1.90",
56
+ "zod": "^3.23.8"
57
+ }
58
+ }
package/src/app.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { type CliRenderer, createCliRenderer } from "@opentui/core";
2
+ import { loadConfig } from "./config";
3
+ import { AppController } from "./controllers/app-controller";
4
+ import { AddTorrentDialog } from "./layout/add-torrent-dialog";
5
+ import { ConfirmDialog } from "./layout/confirm-dialog";
6
+ import { ContentWindow } from "./layout/content-window";
7
+ import { Sidebar } from "./layout/sidebar";
8
+ import { StatusBar } from "./layout/status-bar";
9
+ import { ToastManager } from "./layout/toast-manager";
10
+ import { Store } from "./store";
11
+ import { TorrentBridge } from "./torrent/bridge";
12
+ import type { LayoutDimensions } from "./types/layout";
13
+ import { calculateLayout } from "./utils/layout";
14
+
15
+ const INITIAL_STATE = {
16
+ selectedIndex: 0,
17
+ selectedView: "All",
18
+ torrents: [],
19
+ totalDownloadBps: 0,
20
+ totalUploadBps: 0,
21
+ };
22
+
23
+ export class App {
24
+ private renderer!: CliRenderer;
25
+ private store!: Store;
26
+ private sidebar!: Sidebar;
27
+ private contentWindow!: ContentWindow;
28
+ private statusBar!: StatusBar;
29
+ private toastManager!: ToastManager;
30
+ private controller!: AppController;
31
+ private bridge!: TorrentBridge;
32
+ private addDialog!: AddTorrentDialog;
33
+ private confirmDialog!: ConfirmDialog;
34
+ private layout!: LayoutDimensions;
35
+ private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
36
+
37
+ async start(): Promise<void> {
38
+ const config = loadConfig();
39
+
40
+ this.renderer = await createCliRenderer({ exitOnCtrlC: true });
41
+ this.store = new Store(INITIAL_STATE);
42
+ this.layout = calculateLayout(this.renderer.width, this.renderer.height);
43
+ this.bridge = new TorrentBridge(this.store, config);
44
+
45
+ this.sidebar = new Sidebar(this.renderer, this.store, this.layout);
46
+ this.contentWindow = new ContentWindow(
47
+ this.renderer,
48
+ this.store,
49
+ this.layout,
50
+ );
51
+ this.statusBar = new StatusBar(this.renderer, this.layout);
52
+ this.toastManager = new ToastManager(this.renderer, this.layout);
53
+ this.addDialog = new AddTorrentDialog(
54
+ this.renderer,
55
+ this.layout,
56
+ config.torrentFolder,
57
+ );
58
+ this.confirmDialog = new ConfirmDialog(this.renderer, this.layout);
59
+
60
+ this.controller = new AppController(
61
+ this.renderer,
62
+ this.store,
63
+ this.sidebar,
64
+ this.contentWindow,
65
+ this.toastManager,
66
+ );
67
+ // Wire ConfirmDialog — callbacks are set inside the controller setter
68
+ this.controller.confirmDialog = this.confirmDialog;
69
+
70
+ // Wire controller callbacks
71
+ this.controller.onAddTorrent = () => {
72
+ this.addDialog.open();
73
+ };
74
+
75
+ this.controller.onDialogClose = () => {
76
+ this.addDialog.close();
77
+ };
78
+
79
+ this.controller.onDialogInput = (key) => {
80
+ return this.addDialog.handleInput(key);
81
+ };
82
+
83
+ this.controller.onQuit = async () => {
84
+ await this.bridge.stopAll();
85
+ this.renderer.destroy();
86
+ };
87
+
88
+ this.controller.onPauseTorrent = (id) => {
89
+ this.bridge.pauseTorrent(id);
90
+ };
91
+
92
+ this.controller.onResumeTorrent = (id) => {
93
+ this.bridge.resumeTorrent(id);
94
+ };
95
+
96
+ this.controller.onStartTorrent = (id) => {
97
+ this.bridge.startTorrent(id);
98
+ };
99
+
100
+ this.controller.onRemoveTorrent = (id, deleteFiles) => {
101
+ this.bridge.removeTorrent(id, deleteFiles);
102
+ };
103
+
104
+ this.addDialog.onSelect = async (filePath) => {
105
+ this.controller.focusMode = "global";
106
+ const filename = filePath.split("/").pop() ?? filePath;
107
+ this.toastManager.show({
108
+ id: `add-${Date.now()}`,
109
+ type: "info",
110
+ title: "Adding torrent",
111
+ message: filename,
112
+ });
113
+
114
+ try {
115
+ await this.bridge.addTorrent(filePath);
116
+ this.toastManager.show({
117
+ id: `added-${Date.now()}`,
118
+ type: "success",
119
+ title: "Download started",
120
+ message: filename,
121
+ });
122
+ } catch (err) {
123
+ this.toastManager.show({
124
+ id: `err-${Date.now()}`,
125
+ type: "error",
126
+ title: "Failed to add",
127
+ message: err instanceof Error ? err.message : String(err),
128
+ });
129
+ }
130
+ };
131
+
132
+ this.store.subscribe((state) => {
133
+ this.statusBar.update(state);
134
+ });
135
+
136
+ await this.bridge.restoreSession();
137
+ this.controller.start();
138
+
139
+ this.renderer.on("resize", (width: number, height: number) => {
140
+ this.handleResize(width, height);
141
+ });
142
+ }
143
+
144
+ private handleResize(width: number, height: number): void {
145
+ if (this.resizeTimeout) clearTimeout(this.resizeTimeout);
146
+ this.resizeTimeout = setTimeout(() => {
147
+ this.resizeTimeout = null;
148
+ this.layout = calculateLayout(width, height);
149
+ this.sidebar.updateLayout(this.layout);
150
+ this.contentWindow.updateLayout(this.layout);
151
+ this.statusBar.updateLayout(this.layout);
152
+ this.toastManager.updateLayout(this.layout);
153
+ this.addDialog.updateLayout(this.layout);
154
+ this.confirmDialog.updateLayout(this.layout);
155
+ }, 100);
156
+ }
157
+ }
@@ -0,0 +1,41 @@
1
+ // Config loader - reads from ~/.config/torrent-tui/settings.json
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { getConfigDir, getConfigPath } from "../utils/paths";
5
+ import { type AppSettings, DEFAULT_SETTINGS, settingsSchema } from "./settings";
6
+
7
+ const SETTINGS_FILE = "settings.json";
8
+
9
+ export function loadConfig(): AppSettings {
10
+ const configPath = getConfigPath(SETTINGS_FILE);
11
+
12
+ if (!existsSync(configPath)) {
13
+ // Create config dir and save default settings on first run
14
+ saveConfig(DEFAULT_SETTINGS);
15
+ return { ...DEFAULT_SETTINGS };
16
+ }
17
+
18
+ try {
19
+ const content = readFileSync(configPath, "utf-8");
20
+ const raw = JSON.parse(content);
21
+ const result = settingsSchema.safeParse(raw);
22
+
23
+ if (result.success) {
24
+ return result.data;
25
+ }
26
+
27
+ return { ...DEFAULT_SETTINGS };
28
+ } catch {
29
+ return { ...DEFAULT_SETTINGS };
30
+ }
31
+ }
32
+
33
+ export function saveConfig(settings: AppSettings): void {
34
+ try {
35
+ mkdirSync(getConfigDir(), { recursive: true });
36
+ const configPath = getConfigPath(SETTINGS_FILE);
37
+ writeFileSync(configPath, JSON.stringify(settings, null, 2), "utf-8");
38
+ } catch {
39
+ // Silently fail
40
+ }
41
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+
3
+ export const settingsSchema = z.object({
4
+ downloadPath: z.string().default("~/Downloads"),
5
+ maxConnections: z.number().min(1).max(500).default(50),
6
+ torrentFolder: z.string().default("~/Downloads"),
7
+ });
8
+
9
+ export type AppSettings = z.infer<typeof settingsSchema>;
10
+
11
+ export const DEFAULT_SETTINGS: AppSettings = {
12
+ downloadPath: "~/Downloads",
13
+ maxConnections: 50,
14
+ torrentFolder: "~/Downloads",
15
+ };
@@ -0,0 +1,16 @@
1
+ export const APP_NAME = "torrent-tui";
2
+ export const VERSION = "0.0.1";
3
+
4
+ export const SIDEBAR_WIDTH = 20;
5
+
6
+ export const SIDEBAR_ITEMS = {
7
+ status: ["All", "Downloading", "Seeding", "Completed", "Stopped"],
8
+ } as const;
9
+
10
+ export type SidebarSection = keyof typeof SIDEBAR_ITEMS;
11
+
12
+ // Toast configuration
13
+ export const TOAST_WIDTH = 40;
14
+ export const TOAST_MARGIN = 1;
15
+ export const TOAST_DEFAULT_DURATION = 3000;
16
+ export const TOAST_MAX_COUNT = 3;
@@ -0,0 +1,180 @@
1
+ import type { CliRenderer, KeyEvent } from "@opentui/core";
2
+ import { SIDEBAR_ITEMS } from "../constants";
3
+ import type { ConfirmDialog } from "../layout/confirm-dialog";
4
+ import type { ContentWindow } from "../layout/content-window";
5
+ import type { Sidebar } from "../layout/sidebar";
6
+ import type { ToastManager } from "../layout/toast-manager";
7
+ import type { Store } from "../store";
8
+ import { filterTorrents } from "../utils/filter";
9
+
10
+ type FocusMode = "global" | "dialog";
11
+ type FocusArea = "sidebar" | "table";
12
+
13
+ export class AppController {
14
+ private renderer: CliRenderer;
15
+ private store: Store;
16
+ private sidebar: Sidebar;
17
+ private contentWindow: ContentWindow;
18
+ private toastManager: ToastManager;
19
+ focusMode: FocusMode = "global";
20
+ focusArea: FocusArea = "sidebar";
21
+ private tableSelectedIndex = 0;
22
+ private pendingDeleteId: string | null = null;
23
+
24
+ // Injected by App after bridge/dialog are created
25
+ onAddTorrent?: () => void;
26
+ onQuit?: () => void;
27
+ onDialogClose?: () => void;
28
+ onDialogInput?: (key: string) => boolean;
29
+ onPauseTorrent?: (id: string) => void;
30
+ onResumeTorrent?: (id: string) => void;
31
+ onStartTorrent?: (id: string) => void;
32
+ onRemoveTorrent?: (id: string, deleteFiles: boolean) => void;
33
+
34
+ private _confirmDialog: ConfirmDialog | null = null;
35
+ set confirmDialog(dialog: ConfirmDialog) {
36
+ this._confirmDialog = dialog;
37
+ dialog.onConfirm = () => {
38
+ this.focusMode = "global";
39
+ if (this.pendingDeleteId) {
40
+ this.onRemoveTorrent?.(this.pendingDeleteId, true);
41
+ this.pendingDeleteId = null;
42
+ }
43
+ };
44
+ dialog.onCancel = () => {
45
+ this.focusMode = "global";
46
+ this.pendingDeleteId = null;
47
+ };
48
+ }
49
+
50
+ constructor(
51
+ renderer: CliRenderer,
52
+ store: Store,
53
+ sidebar: Sidebar,
54
+ contentWindow: ContentWindow,
55
+ toastManager: ToastManager,
56
+ ) {
57
+ this.renderer = renderer;
58
+ this.store = store;
59
+ this.sidebar = sidebar;
60
+ this.contentWindow = contentWindow;
61
+ this.toastManager = toastManager;
62
+ }
63
+
64
+ start(): void {
65
+ this.store.subscribe((state) => {
66
+ const len = filterTorrents(state.torrents, state.selectedView).length;
67
+ if (len > 0 && this.tableSelectedIndex >= len) {
68
+ this.tableSelectedIndex = len - 1;
69
+ } else if (len === 0) {
70
+ this.tableSelectedIndex = 0;
71
+ }
72
+ this.sidebar.update(state, this.focusArea);
73
+ this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
74
+ });
75
+
76
+ this.renderer.keyInput.on("keypress", (key) => {
77
+ this.handleKeyPress(key);
78
+ });
79
+
80
+ this.refreshView();
81
+ }
82
+
83
+ private refreshView(): void {
84
+ const state = this.store.getState();
85
+ this.sidebar.update(state, this.focusArea);
86
+ this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
87
+ }
88
+
89
+ private getSelectedId(): string | null {
90
+ const state = this.store.getState();
91
+ return filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex]?.id ?? null;
92
+ }
93
+
94
+ private handleKeyPress(key: KeyEvent): void {
95
+ if (this.focusMode === "dialog") {
96
+ if (this._confirmDialog?.getIsOpen()) {
97
+ this._confirmDialog.handleInput(key.name);
98
+ } else {
99
+ if (key.name === "escape") {
100
+ this.focusMode = "global";
101
+ this.onDialogClose?.();
102
+ } else {
103
+ this.onDialogInput?.(key.name);
104
+ }
105
+ }
106
+ return;
107
+ }
108
+
109
+ if (this.toastManager.handleInput(key.name)) {
110
+ return;
111
+ }
112
+
113
+ if (key.name === "tab") {
114
+ this.focusArea = this.focusArea === "sidebar" ? "table" : "sidebar";
115
+ this.refreshView();
116
+ return;
117
+ }
118
+
119
+ if (key.name === "j" || key.name === "down") {
120
+ if (this.focusArea === "sidebar") {
121
+ const total = SIDEBAR_ITEMS.status.length;
122
+ const state = this.store.getState();
123
+ const next = (state.selectedIndex + 1) % total;
124
+ this.store.setState({ selectedIndex: next, selectedView: SIDEBAR_ITEMS.status[next] ?? "All" });
125
+ } else {
126
+ const state = this.store.getState();
127
+ const len = filterTorrents(state.torrents, state.selectedView).length;
128
+ if (len > 0) {
129
+ this.tableSelectedIndex = Math.min(this.tableSelectedIndex + 1, len - 1);
130
+ this.refreshView();
131
+ }
132
+ }
133
+ } else if (key.name === "k" || key.name === "up") {
134
+ if (this.focusArea === "sidebar") {
135
+ const total = SIDEBAR_ITEMS.status.length;
136
+ const state = this.store.getState();
137
+ const prev = (state.selectedIndex - 1 + total) % total;
138
+ this.store.setState({ selectedIndex: prev, selectedView: SIDEBAR_ITEMS.status[prev] ?? "All" });
139
+ } else {
140
+ if (this.tableSelectedIndex > 0) {
141
+ this.tableSelectedIndex--;
142
+ this.refreshView();
143
+ }
144
+ }
145
+ } else if (key.name === "space") {
146
+ if (this.focusArea === "table") {
147
+ const id = this.getSelectedId();
148
+ if (!id) return;
149
+ const state = this.store.getState();
150
+ const torrent = filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex];
151
+ if (!torrent) return;
152
+ if (torrent.status === "downloading") {
153
+ this.onPauseTorrent?.(id);
154
+ } else if (torrent.status === "paused") {
155
+ this.onResumeTorrent?.(id);
156
+ } else if (torrent.status === "stopped") {
157
+ this.onStartTorrent?.(id);
158
+ }
159
+ }
160
+ } else if (key.name === "d" && !key.shift) {
161
+ if (this.focusArea === "table") {
162
+ const id = this.getSelectedId();
163
+ if (id) this.onRemoveTorrent?.(id, false);
164
+ }
165
+ } else if (key.name === "d" && key.shift) {
166
+ if (this.focusArea === "table") {
167
+ const id = this.getSelectedId();
168
+ if (!id) return;
169
+ this.pendingDeleteId = id;
170
+ this.focusMode = "dialog";
171
+ this._confirmDialog?.open("Delete torrent and files?");
172
+ }
173
+ } else if (key.name === "a") {
174
+ this.focusMode = "dialog";
175
+ this.onAddTorrent?.();
176
+ } else if (key.name === "q") {
177
+ this.onQuit?.();
178
+ }
179
+ }
180
+ }