torrent-tui 0.0.1 → 0.0.3

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.
package/README.md CHANGED
@@ -1,13 +1,17 @@
1
1
  # torrent-tui
2
2
 
3
- **A Bun-powered terminal BitTorrent client.** Add `.torrent` files, manage active downloads, and inspect transfer state from a focused TUI.
3
+ **A terminal BitTorrent client for focused download management.** Add `.torrent` files, track active transfers, and manage sessions from a clean keyboard-driven interface.
4
4
 
5
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)
6
+ [![gzipped](https://img.shields.io/badge/gzipped-39%20KB-2563eb?style=for-the-badge)](https://www.npmjs.com/package/torrent-tui)
7
+ [![npm unpacked size](https://img.shields.io/npm/unpacked-size/torrent-tui?style=for-the-badge)](https://www.npmjs.com/package/torrent-tui)
8
+ [![CI](https://img.shields.io/github/actions/workflow/status/ryadios/torrent-tui/release.yml?branch=main&style=for-the-badge&logo=github)](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
7
9
  [![license](https://img.shields.io/npm/l/torrent-tui?style=for-the-badge)](./LICENSE)
8
10
 
9
11
  [Install](#install) · [Quickstart](#quickstart) · [Commands](#commands) · [Configuration](#configuration) · [Development](#development)
10
12
 
13
+ ![torrent-tui terminal interface](./docs/screenshot.png)
14
+
11
15
  > [!NOTE]
12
16
  > `torrent-tui` currently requires [Bun](https://bun.sh). Standalone binaries are planned after the npm CLI release path is stable.
13
17
 
@@ -63,6 +67,7 @@ The package also exposes a few command-line checks around the same torrent engin
63
67
  ```bash
64
68
  torrent-tui --help
65
69
  torrent-tui --version
70
+ torrent-tui file.torrent
66
71
  torrent-tui file.torrent --verify
67
72
  torrent-tui file.torrent --handshake
68
73
  torrent-tui file.torrent --download
@@ -71,7 +76,7 @@ torrent-tui file.torrent --download
71
76
  | Command | Description |
72
77
  | --- | --- |
73
78
  | `torrent-tui` | Start the terminal UI. |
74
- | `torrent-tui <file.torrent>` | Announce to trackers and print peers. |
79
+ | `torrent-tui <file.torrent>` | Start the TUI and add the torrent. |
75
80
  | `torrent-tui <file.torrent> --verify` | Create storage and verify local pieces. |
76
81
  | `torrent-tui <file.torrent> --handshake` | Connect to peers and print a connection summary. |
77
82
  | `torrent-tui <file.torrent> --download` | Run the downloader without launching the TUI. |
@@ -100,6 +105,51 @@ Resume data is stored under:
100
105
  ${XDG_DATA_HOME:-~/.local/share}/torrent-tui/resume
101
106
  ```
102
107
 
108
+ The session registry is stored at:
109
+
110
+ ```text
111
+ ${XDG_DATA_HOME:-~/.local/share}/torrent-tui/session.json
112
+ ```
113
+
114
+ It keeps the list of torrents the TUI should restore on startup.
115
+
116
+ ### Torrent States
117
+
118
+ The TUI shows detailed per-torrent states while keeping the sidebar filters simple.
119
+
120
+ | State | Meaning |
121
+ | --- | --- |
122
+ | `Queued` | The `.torrent` was accepted and is waiting for engine startup. |
123
+ | `Checking` | Local files are being checked against torrent piece hashes. |
124
+ | `Connecting` | Trackers were contacted and the client is connecting to peers. |
125
+ | `Downloading` | Pieces are actively being requested or received. |
126
+ | `Stalled` | The torrent is incomplete but has no usable peers right now. Press `Space` to retry. |
127
+ | `Paused` | The active downloader was paused by the user. |
128
+ | `Seeding` | All pieces are present and the torrent can upload to peers. |
129
+ | `Stopped` | The torrent is saved in the session but not running. |
130
+ | `Error` | Startup, storage, or torrent metadata handling failed. |
131
+
132
+ The `Downloading` sidebar filter includes queued, checking, connecting, downloading, and stalled torrents so active work stays grouped together.
133
+
134
+ ### What Each Setting Does
135
+
136
+ | Setting | Purpose | When it applies |
137
+ | --- | --- | --- |
138
+ | `downloadPath` | Where torrent payload files are written and verified. | On torrent add, resume, verify, and startup restore. |
139
+ | `torrentFolder` | Folder shown by the add-torrent dialog. | When you open the add dialog. |
140
+ | `maxConnections` | Maximum number of peers the client will connect to per torrent. | During peer discovery and download. |
141
+
142
+ ### Tuning Tips
143
+
144
+ - Use a fast local SSD for `downloadPath` if you want quicker verification and fewer stalls on reopen.
145
+ - Point `torrentFolder` at the directory where you keep `.torrent` files so adding torrents is faster.
146
+ - Lower `maxConnections` if your network or CPU struggles with many peers; raise it if you want more parallel peer selection.
147
+ - Fresh torrents skip full zero-file verification. Existing files are checked cooperatively, so the TUI should stay responsive during large rechecks.
148
+ - If a torrent stays `Stalled`, the client did not find a usable peer. Try again later with `Space`, or check tracker availability.
149
+ - Settings are read when the app starts. If you edit `settings.json` manually, restart the app to pick up the changes.
150
+ - If the settings file is invalid, torrent-tui falls back to defaults and logs a config warning.
151
+ - `session.json` and the resume files are rewritten automatically as torrent state changes, so you normally do not need to edit them by hand.
152
+
103
153
  ## Status
104
154
 
105
155
  `0.0.1` is a basic release intended for early CLI usage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "torrent-tui",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "A Bun-powered terminal BitTorrent client.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -20,9 +20,13 @@
20
20
  "dev": "bun --watch src/index.ts",
21
21
  "check": "bun src/index.ts --download",
22
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
+ "bench:startup": "bun scripts/benchmark-startup.ts",
24
+ "bench:verify": "bun scripts/benchmark-verify.ts",
25
+ "test": "bun test",
26
+ "test:watch": "bun test --watch",
23
27
  "typecheck": "tsc --noEmit",
24
28
  "check:fix": "biome check --write --unsafe",
25
- "release:check": "bun run typecheck && bun publish --dry-run"
29
+ "release:check": "bun run typecheck && bun test && bun publish --dry-run"
26
30
  },
27
31
  "keywords": [
28
32
  "bittorrent",
package/src/app.ts CHANGED
@@ -34,7 +34,7 @@ export class App {
34
34
  private layout!: LayoutDimensions;
35
35
  private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
36
36
 
37
- async start(): Promise<void> {
37
+ async start(initialTorrentPath?: string): Promise<void> {
38
38
  const config = loadConfig();
39
39
 
40
40
  this.renderer = await createCliRenderer({ exitOnCtrlC: true });
@@ -62,6 +62,7 @@ export class App {
62
62
  this.store,
63
63
  this.sidebar,
64
64
  this.contentWindow,
65
+ this.statusBar,
65
66
  this.toastManager,
66
67
  );
67
68
  // Wire ConfirmDialog — callbacks are set inside the controller setter
@@ -101,41 +102,27 @@ export class App {
101
102
  this.bridge.removeTorrent(id, deleteFiles);
102
103
  };
103
104
 
104
- this.addDialog.onSelect = async (filePath) => {
105
+ this.addDialog.onSelect = (filePath) => {
105
106
  this.controller.focusMode = "global";
106
107
  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
- });
108
+ this.renderer.requestRender();
113
109
 
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
- }
110
+ setTimeout(() => {
111
+ this.addTorrentInBackground(filePath, filename);
112
+ }, 0);
130
113
  };
131
114
 
132
- this.store.subscribe((state) => {
133
- this.statusBar.update(state);
134
- });
135
-
136
115
  await this.bridge.restoreSession();
137
116
  this.controller.start();
138
117
 
118
+ if (initialTorrentPath) {
119
+ const filename =
120
+ initialTorrentPath.split("/").pop() ?? initialTorrentPath;
121
+ setTimeout(() => {
122
+ this.addTorrentInBackground(initialTorrentPath, filename);
123
+ }, 0);
124
+ }
125
+
139
126
  this.renderer.on("resize", (width: number, height: number) => {
140
127
  this.handleResize(width, height);
141
128
  });
@@ -154,4 +141,38 @@ export class App {
154
141
  this.confirmDialog.updateLayout(this.layout);
155
142
  }, 100);
156
143
  }
144
+
145
+ private async addTorrentInBackground(
146
+ filePath: string,
147
+ filename: string,
148
+ ): Promise<void> {
149
+ try {
150
+ const result = await this.bridge.addTorrent(filePath);
151
+ this.toastManager.show({
152
+ id: `added-${Date.now()}`,
153
+ type: result.added ? "success" : "info",
154
+ title: result.added ? "Torrent added" : "Torrent already added",
155
+ message: result.name || filename,
156
+ });
157
+ this.renderer.requestRender();
158
+
159
+ if (result.added) {
160
+ this.bridge.startTorrent(result.id).catch((err: unknown) => {
161
+ this.toastManager.show({
162
+ id: `start-err-${Date.now()}`,
163
+ type: "error",
164
+ title: "Failed to start",
165
+ message: err instanceof Error ? err.message : String(err),
166
+ });
167
+ });
168
+ }
169
+ } catch (err) {
170
+ this.toastManager.show({
171
+ id: `err-${Date.now()}`,
172
+ type: "error",
173
+ title: "Failed to add",
174
+ message: err instanceof Error ? err.message : String(err),
175
+ });
176
+ }
177
+ }
157
178
  }
@@ -1,7 +1,9 @@
1
1
  // Config loader - reads from ~/.config/torrent-tui/settings.json
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { getConfigDir, getConfigPath } from "../utils/paths";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { log } from "../torrent/metadata";
5
+ import { writeJsonAtomic } from "../utils/json";
6
+ import { getConfigPath } from "../utils/paths";
5
7
  import { type AppSettings, DEFAULT_SETTINGS, settingsSchema } from "./settings";
6
8
 
7
9
  const SETTINGS_FILE = "settings.json";
@@ -24,18 +26,14 @@ export function loadConfig(): AppSettings {
24
26
  return result.data;
25
27
  }
26
28
 
29
+ log("config", "invalid settings file — using defaults");
27
30
  return { ...DEFAULT_SETTINGS };
28
31
  } catch {
32
+ log("config", "could not read settings file — using defaults");
29
33
  return { ...DEFAULT_SETTINGS };
30
34
  }
31
35
  }
32
36
 
33
37
  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
- }
38
+ writeJsonAtomic(getConfigPath(SETTINGS_FILE), settings);
41
39
  }
@@ -1,5 +1,4 @@
1
1
  export const APP_NAME = "torrent-tui";
2
- export const VERSION = "0.0.1";
3
2
 
4
3
  export const SIDEBAR_WIDTH = 20;
5
4
 
@@ -1,24 +1,37 @@
1
1
  import type { CliRenderer, KeyEvent } from "@opentui/core";
2
2
  import { SIDEBAR_ITEMS } from "../constants";
3
3
  import type { ConfirmDialog } from "../layout/confirm-dialog";
4
- import type { ContentWindow } from "../layout/content-window";
4
+ import type { ContentWindow, FocusArea } from "../layout/content-window";
5
+ import {
6
+ type DetailTab,
7
+ getDetailMaxScrollOffset,
8
+ } from "../layout/detail-panel";
5
9
  import type { Sidebar } from "../layout/sidebar";
10
+ import type { StatusBar } from "../layout/status-bar";
6
11
  import type { ToastManager } from "../layout/toast-manager";
7
12
  import type { Store } from "../store";
8
13
  import { filterTorrents } from "../utils/filter";
9
14
 
10
15
  type FocusMode = "global" | "dialog";
11
- type FocusArea = "sidebar" | "table";
16
+ const DETAIL_TABS: DetailTab[] = ["Pieces", "Peers", "Files"];
12
17
 
13
18
  export class AppController {
14
19
  private renderer: CliRenderer;
15
20
  private store: Store;
16
21
  private sidebar: Sidebar;
17
22
  private contentWindow: ContentWindow;
23
+ private statusBar: StatusBar;
18
24
  private toastManager: ToastManager;
19
25
  focusMode: FocusMode = "global";
20
26
  focusArea: FocusArea = "sidebar";
21
27
  private tableSelectedIndex = 0;
28
+ private detailTabIndex = 0;
29
+ private detailScrollOffsets: Record<DetailTab, number> = {
30
+ Pieces: 0,
31
+ Peers: 0,
32
+ Files: 0,
33
+ };
34
+ private lastDetailTorrentId: string | null = null;
22
35
  private pendingDeleteId: string | null = null;
23
36
 
24
37
  // Injected by App after bridge/dialog are created
@@ -26,18 +39,21 @@ export class AppController {
26
39
  onQuit?: () => void;
27
40
  onDialogClose?: () => void;
28
41
  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;
42
+ onPauseTorrent?: (id: string) => Promise<void> | void;
43
+ onResumeTorrent?: (id: string) => Promise<void> | void;
44
+ onStartTorrent?: (id: string) => Promise<void> | void;
45
+ onRemoveTorrent?: (id: string, deleteFiles: boolean) => Promise<void> | void;
33
46
 
34
47
  private _confirmDialog: ConfirmDialog | null = null;
35
48
  set confirmDialog(dialog: ConfirmDialog) {
36
49
  this._confirmDialog = dialog;
37
50
  dialog.onConfirm = () => {
38
51
  this.focusMode = "global";
39
- if (this.pendingDeleteId) {
40
- this.onRemoveTorrent?.(this.pendingDeleteId, true);
52
+ const pendingDeleteId = this.pendingDeleteId;
53
+ if (pendingDeleteId) {
54
+ this.runTorrentAction("remove torrent", () =>
55
+ this.onRemoveTorrent?.(pendingDeleteId, true),
56
+ );
41
57
  this.pendingDeleteId = null;
42
58
  }
43
59
  };
@@ -52,12 +68,14 @@ export class AppController {
52
68
  store: Store,
53
69
  sidebar: Sidebar,
54
70
  contentWindow: ContentWindow,
71
+ statusBar: StatusBar,
55
72
  toastManager: ToastManager,
56
73
  ) {
57
74
  this.renderer = renderer;
58
75
  this.store = store;
59
76
  this.sidebar = sidebar;
60
77
  this.contentWindow = contentWindow;
78
+ this.statusBar = statusBar;
61
79
  this.toastManager = toastManager;
62
80
  }
63
81
 
@@ -69,8 +87,15 @@ export class AppController {
69
87
  } else if (len === 0) {
70
88
  this.tableSelectedIndex = 0;
71
89
  }
90
+ this.syncDetailState();
72
91
  this.sidebar.update(state, this.focusArea);
73
- this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
92
+ this.contentWindow.update(
93
+ this.focusArea,
94
+ this.tableSelectedIndex,
95
+ this.getDetailTab(),
96
+ this.getDetailScrollOffset(),
97
+ );
98
+ this.statusBar.update(state, this.focusArea);
74
99
  });
75
100
 
76
101
  this.renderer.keyInput.on("keypress", (key) => {
@@ -83,12 +108,104 @@ export class AppController {
83
108
  private refreshView(): void {
84
109
  const state = this.store.getState();
85
110
  this.sidebar.update(state, this.focusArea);
86
- this.contentWindow.update(this.focusArea, this.tableSelectedIndex);
111
+ this.contentWindow.update(
112
+ this.focusArea,
113
+ this.tableSelectedIndex,
114
+ this.getDetailTab(),
115
+ this.getDetailScrollOffset(),
116
+ );
117
+ this.statusBar.update(state, this.focusArea);
118
+ }
119
+
120
+ private runTorrentAction(
121
+ label: string,
122
+ action: () => Promise<void> | void,
123
+ ): void {
124
+ Promise.resolve()
125
+ .then(action)
126
+ .catch((err: unknown) => {
127
+ this.toastManager.show({
128
+ id: `action-${Date.now()}`,
129
+ type: "error",
130
+ title: `Failed to ${label}`,
131
+ message: err instanceof Error ? err.message : String(err),
132
+ });
133
+ });
87
134
  }
88
135
 
89
136
  private getSelectedId(): string | null {
90
137
  const state = this.store.getState();
91
- return filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex]?.id ?? null;
138
+ return (
139
+ filterTorrents(state.torrents, state.selectedView)[
140
+ this.tableSelectedIndex
141
+ ]?.id ?? null
142
+ );
143
+ }
144
+
145
+ private getDetailTab(): DetailTab {
146
+ return DETAIL_TABS[this.detailTabIndex] ?? "Pieces";
147
+ }
148
+
149
+ private moveDetailTab(delta: number): void {
150
+ this.detailTabIndex =
151
+ (this.detailTabIndex + delta + DETAIL_TABS.length) % DETAIL_TABS.length;
152
+ this.refreshView();
153
+ }
154
+
155
+ private getDetailScrollOffset(): number {
156
+ return this.detailScrollOffsets[this.getDetailTab()] ?? 0;
157
+ }
158
+
159
+ private setDetailScrollOffset(offset: number): void {
160
+ this.detailScrollOffsets[this.getDetailTab()] = offset;
161
+ }
162
+
163
+ private resetDetailScrollOffsets(): void {
164
+ this.detailScrollOffsets = {
165
+ Pieces: 0,
166
+ Peers: 0,
167
+ Files: 0,
168
+ };
169
+ }
170
+
171
+ private getSelectedTorrent() {
172
+ const state = this.store.getState();
173
+ return (
174
+ filterTorrents(state.torrents, state.selectedView)[
175
+ this.tableSelectedIndex
176
+ ] ?? null
177
+ );
178
+ }
179
+
180
+ private syncDetailState(): void {
181
+ const torrentId = this.getSelectedTorrent()?.id ?? null;
182
+ if (torrentId !== this.lastDetailTorrentId) {
183
+ this.lastDetailTorrentId = torrentId;
184
+ this.resetDetailScrollOffsets();
185
+ }
186
+ const maxOffset = getDetailMaxScrollOffset(
187
+ this.getSelectedTorrent(),
188
+ this.getDetailTab(),
189
+ this.contentWindow.getDetailBodyRowCount(),
190
+ );
191
+ this.setDetailScrollOffset(
192
+ Math.max(0, Math.min(this.getDetailScrollOffset(), maxOffset)),
193
+ );
194
+ }
195
+
196
+ private moveDetailScroll(delta: number): void {
197
+ const maxOffset = getDetailMaxScrollOffset(
198
+ this.getSelectedTorrent(),
199
+ this.getDetailTab(),
200
+ this.contentWindow.getDetailBodyRowCount(),
201
+ );
202
+ const nextOffset = Math.max(
203
+ 0,
204
+ Math.min(this.getDetailScrollOffset() + delta, maxOffset),
205
+ );
206
+ if (nextOffset === this.getDetailScrollOffset()) return;
207
+ this.setDetailScrollOffset(nextOffset);
208
+ this.refreshView();
92
209
  }
93
210
 
94
211
  private handleKeyPress(key: KeyEvent): void {
@@ -110,57 +227,113 @@ export class AppController {
110
227
  return;
111
228
  }
112
229
 
230
+ if (key.shift && key.name === "tab") {
231
+ this.focusArea = this.previousFocusArea();
232
+ this.refreshView();
233
+ return;
234
+ }
235
+
113
236
  if (key.name === "tab") {
114
- this.focusArea = this.focusArea === "sidebar" ? "table" : "sidebar";
237
+ this.focusArea = this.nextFocusArea();
115
238
  this.refreshView();
116
239
  return;
117
240
  }
118
241
 
242
+ if (this.focusArea === "details") {
243
+ if (
244
+ key.name === "h" ||
245
+ key.name === "[" ||
246
+ key.name === "leftbracket" ||
247
+ key.name === "left"
248
+ ) {
249
+ this.moveDetailTab(-1);
250
+ return;
251
+ }
252
+ if (
253
+ key.name === "l" ||
254
+ key.name === "]" ||
255
+ key.name === "rightbracket" ||
256
+ key.name === "right"
257
+ ) {
258
+ this.moveDetailTab(1);
259
+ return;
260
+ }
261
+ }
262
+
119
263
  if (key.name === "j" || key.name === "down") {
120
264
  if (this.focusArea === "sidebar") {
121
265
  const total = SIDEBAR_ITEMS.status.length;
122
266
  const state = this.store.getState();
123
267
  const next = (state.selectedIndex + 1) % total;
124
- this.store.setState({ selectedIndex: next, selectedView: SIDEBAR_ITEMS.status[next] ?? "All" });
125
- } else {
268
+ this.store.setState({
269
+ selectedIndex: next,
270
+ selectedView: SIDEBAR_ITEMS.status[next] ?? "All",
271
+ });
272
+ } else if (this.focusArea === "table") {
126
273
  const state = this.store.getState();
127
274
  const len = filterTorrents(state.torrents, state.selectedView).length;
128
275
  if (len > 0) {
129
- this.tableSelectedIndex = Math.min(this.tableSelectedIndex + 1, len - 1);
276
+ this.tableSelectedIndex = Math.min(
277
+ this.tableSelectedIndex + 1,
278
+ len - 1,
279
+ );
130
280
  this.refreshView();
131
281
  }
282
+ } else if (this.focusArea === "details") {
283
+ this.moveDetailScroll(1);
132
284
  }
133
285
  } else if (key.name === "k" || key.name === "up") {
134
286
  if (this.focusArea === "sidebar") {
135
287
  const total = SIDEBAR_ITEMS.status.length;
136
288
  const state = this.store.getState();
137
289
  const prev = (state.selectedIndex - 1 + total) % total;
138
- this.store.setState({ selectedIndex: prev, selectedView: SIDEBAR_ITEMS.status[prev] ?? "All" });
139
- } else {
290
+ this.store.setState({
291
+ selectedIndex: prev,
292
+ selectedView: SIDEBAR_ITEMS.status[prev] ?? "All",
293
+ });
294
+ } else if (this.focusArea === "table") {
140
295
  if (this.tableSelectedIndex > 0) {
141
296
  this.tableSelectedIndex--;
142
297
  this.refreshView();
143
298
  }
299
+ } else if (this.focusArea === "details") {
300
+ this.moveDetailScroll(-1);
144
301
  }
145
302
  } else if (key.name === "space") {
146
303
  if (this.focusArea === "table") {
147
304
  const id = this.getSelectedId();
148
305
  if (!id) return;
149
306
  const state = this.store.getState();
150
- const torrent = filterTorrents(state.torrents, state.selectedView)[this.tableSelectedIndex];
307
+ const torrent = filterTorrents(state.torrents, state.selectedView)[
308
+ this.tableSelectedIndex
309
+ ];
151
310
  if (!torrent) return;
152
311
  if (torrent.status === "downloading") {
153
- this.onPauseTorrent?.(id);
312
+ this.runTorrentAction("pause torrent", () =>
313
+ this.onPauseTorrent?.(id),
314
+ );
154
315
  } else if (torrent.status === "paused") {
155
- this.onResumeTorrent?.(id);
156
- } else if (torrent.status === "stopped") {
157
- this.onStartTorrent?.(id);
316
+ this.runTorrentAction("resume torrent", () =>
317
+ this.onResumeTorrent?.(id),
318
+ );
319
+ } else if (
320
+ torrent.status === "stopped" ||
321
+ torrent.status === "stalled" ||
322
+ torrent.status === "error" ||
323
+ torrent.status === "missing"
324
+ ) {
325
+ this.runTorrentAction("start torrent", () =>
326
+ this.onStartTorrent?.(id),
327
+ );
158
328
  }
159
329
  }
160
330
  } else if (key.name === "d" && !key.shift) {
161
331
  if (this.focusArea === "table") {
162
332
  const id = this.getSelectedId();
163
- if (id) this.onRemoveTorrent?.(id, false);
333
+ if (id)
334
+ this.runTorrentAction("remove torrent", () =>
335
+ this.onRemoveTorrent?.(id, false),
336
+ );
164
337
  }
165
338
  } else if (key.name === "d" && key.shift) {
166
339
  if (this.focusArea === "table") {
@@ -177,4 +350,26 @@ export class AppController {
177
350
  this.onQuit?.();
178
351
  }
179
352
  }
353
+
354
+ private nextFocusArea(): FocusArea {
355
+ switch (this.focusArea) {
356
+ case "sidebar":
357
+ return "table";
358
+ case "table":
359
+ return "details";
360
+ case "details":
361
+ return "sidebar";
362
+ }
363
+ }
364
+
365
+ private previousFocusArea(): FocusArea {
366
+ switch (this.focusArea) {
367
+ case "sidebar":
368
+ return "details";
369
+ case "table":
370
+ return "sidebar";
371
+ case "details":
372
+ return "table";
373
+ }
374
+ }
180
375
  }