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 +53 -3
- package/package.json +6 -2
- package/src/app.ts +49 -28
- package/src/config/index.ts +7 -9
- package/src/constants/index.ts +0 -1
- package/src/controllers/app-controller.ts +218 -23
- package/src/index.ts +15 -19
- package/src/layout/add-torrent-dialog.ts +50 -21
- package/src/layout/confirm-dialog.ts +37 -15
- package/src/layout/content-window.ts +130 -25
- package/src/layout/detail-panel.ts +435 -0
- package/src/layout/sidebar.ts +5 -2
- package/src/layout/status-bar.ts +23 -5
- package/src/layout/toast.ts +1 -2
- package/src/layout/torrent-view.ts +164 -50
- package/src/store/index.ts +30 -1
- package/src/torrent/bridge.ts +422 -58
- package/src/torrent/downloader.ts +57 -122
- package/src/torrent/metadata.ts +36 -21
- package/src/torrent/parser.ts +8 -4
- package/src/torrent/peer/connection.ts +22 -17
- package/src/torrent/peer/listener.ts +1 -1
- package/src/torrent/peer/manager.ts +21 -18
- package/src/torrent/piece-picker.ts +4 -1
- package/src/torrent/resume.ts +147 -0
- package/src/torrent/session.ts +53 -8
- package/src/torrent/storage.ts +301 -48
- package/src/torrent/tracker/announce.ts +6 -3
- package/src/torrent/tracker/http-tracker.ts +74 -43
- package/src/torrent/tracker/udp-tracker.ts +69 -27
- package/src/torrent/types.ts +7 -2
- package/src/utils/filter.ts +27 -6
- package/src/utils/json.ts +23 -0
package/README.md
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# torrent-tui
|
|
2
2
|
|
|
3
|
-
**A
|
|
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
|
[](https://www.npmjs.com/package/torrent-tui)
|
|
6
|
-
[](https://www.npmjs.com/package/torrent-tui)
|
|
7
|
+
[](https://www.npmjs.com/package/torrent-tui)
|
|
8
|
+
[](https://github.com/ryadios/torrent-tui/actions/workflows/ci.yml)
|
|
7
9
|
[](./LICENSE)
|
|
8
10
|
|
|
9
11
|
[Install](#install) · [Quickstart](#quickstart) · [Commands](#commands) · [Configuration](#configuration) · [Development](#development)
|
|
10
12
|
|
|
13
|
+

|
|
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>` |
|
|
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.
|
|
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 =
|
|
105
|
+
this.addDialog.onSelect = (filePath) => {
|
|
105
106
|
this.controller.focusMode = "global";
|
|
106
107
|
const filename = filePath.split("/").pop() ?? filePath;
|
|
107
|
-
this.
|
|
108
|
-
id: `add-${Date.now()}`,
|
|
109
|
-
type: "info",
|
|
110
|
-
title: "Adding torrent",
|
|
111
|
-
message: filename,
|
|
112
|
-
});
|
|
108
|
+
this.renderer.requestRender();
|
|
113
109
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Config loader - reads from ~/.config/torrent-tui/settings.json
|
|
2
2
|
|
|
3
|
-
import { existsSync,
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
package/src/constants/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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({
|
|
125
|
-
|
|
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(
|
|
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({
|
|
139
|
-
|
|
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)[
|
|
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.
|
|
312
|
+
this.runTorrentAction("pause torrent", () =>
|
|
313
|
+
this.onPauseTorrent?.(id),
|
|
314
|
+
);
|
|
154
315
|
} else if (torrent.status === "paused") {
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
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)
|
|
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
|
}
|