torrent-tui 0.0.2 → 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 +50 -2
- package/package.json +6 -2
- package/src/app.ts +49 -28
- package/src/config/index.ts +7 -9
- package/src/controllers/app-controller.ts +218 -23
- package/src/index.ts +5 -14
- 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
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { BoxRenderable, type CliRenderer, TextRenderable } from "@opentui/core";
|
|
4
4
|
import { getTheme } from "../theme";
|
|
5
5
|
import type { LayoutDimensions } from "../types/layout";
|
|
6
6
|
import { resolvePath } from "../utils/paths";
|
|
7
7
|
|
|
8
|
-
const DIALOG_WIDTH
|
|
8
|
+
const DIALOG_WIDTH = 60;
|
|
9
9
|
const DIALOG_HEIGHT = 16;
|
|
10
|
-
const INNER_W
|
|
11
|
-
const MARGIN
|
|
10
|
+
const INNER_W = DIALOG_WIDTH - 2;
|
|
11
|
+
const MARGIN = 2;
|
|
12
12
|
|
|
13
13
|
function truncateName(name: string): string {
|
|
14
14
|
const max = INNER_W - MARGIN * 2;
|
|
15
|
-
return name.length > max ? name.slice(0, max - 1)
|
|
15
|
+
return name.length > max ? `${name.slice(0, max - 1)}…` : name;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function setBg(node: BoxRenderable, bg: string | undefined): void {
|
|
19
|
-
(node as unknown as { backgroundColor: string | undefined }).backgroundColor =
|
|
19
|
+
(node as unknown as { backgroundColor: string | undefined }).backgroundColor =
|
|
20
|
+
bg;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export class AddTorrentDialog {
|
|
@@ -31,7 +32,11 @@ export class AddTorrentDialog {
|
|
|
31
32
|
|
|
32
33
|
onSelect?: (filePath: string) => void;
|
|
33
34
|
|
|
34
|
-
constructor(
|
|
35
|
+
constructor(
|
|
36
|
+
renderer: CliRenderer,
|
|
37
|
+
layout: LayoutDimensions,
|
|
38
|
+
torrentFolder: string,
|
|
39
|
+
) {
|
|
35
40
|
this.renderer = renderer;
|
|
36
41
|
this.layout = layout;
|
|
37
42
|
this.torrentFolder = resolvePath(torrentFolder);
|
|
@@ -93,14 +98,23 @@ export class AddTorrentDialog {
|
|
|
93
98
|
private updateHighlight(): void {
|
|
94
99
|
const theme = getTheme();
|
|
95
100
|
for (let i = 0; i < this.itemRows.length; i++) {
|
|
96
|
-
setBg(
|
|
101
|
+
setBg(
|
|
102
|
+
this.itemRows[i]!,
|
|
103
|
+
i === this.selectedIndex ? theme.bgTertiary : undefined,
|
|
104
|
+
);
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
private build(): void {
|
|
101
109
|
const theme = getTheme();
|
|
102
|
-
const left = Math.max(
|
|
103
|
-
|
|
110
|
+
const left = Math.max(
|
|
111
|
+
0,
|
|
112
|
+
Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2),
|
|
113
|
+
);
|
|
114
|
+
const top = Math.max(
|
|
115
|
+
0,
|
|
116
|
+
Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2),
|
|
117
|
+
);
|
|
104
118
|
|
|
105
119
|
const container = new BoxRenderable(this.renderer, {
|
|
106
120
|
position: "absolute",
|
|
@@ -122,8 +136,18 @@ export class AddTorrentDialog {
|
|
|
122
136
|
paddingLeft: MARGIN,
|
|
123
137
|
paddingRight: MARGIN,
|
|
124
138
|
});
|
|
125
|
-
titleRow.add(
|
|
126
|
-
|
|
139
|
+
titleRow.add(
|
|
140
|
+
new TextRenderable(this.renderer, {
|
|
141
|
+
content: "Add Torrent",
|
|
142
|
+
fg: theme.accent,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
titleRow.add(
|
|
146
|
+
new TextRenderable(this.renderer, {
|
|
147
|
+
content: "Esc to close",
|
|
148
|
+
fg: theme.fgMuted,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
127
151
|
container.add(titleRow);
|
|
128
152
|
|
|
129
153
|
// Spacer
|
|
@@ -132,22 +156,27 @@ export class AddTorrentDialog {
|
|
|
132
156
|
this.itemRows = [];
|
|
133
157
|
|
|
134
158
|
if (this.files.length === 0) {
|
|
135
|
-
container.add(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
159
|
+
container.add(
|
|
160
|
+
new TextRenderable(this.renderer, {
|
|
161
|
+
content: `${" ".repeat(MARGIN)}No .torrent files in ${this.torrentFolder}`,
|
|
162
|
+
fg: theme.fgMuted,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
139
165
|
} else {
|
|
140
166
|
for (let i = 0; i < this.files.length; i++) {
|
|
141
167
|
const file = this.files[i]!;
|
|
142
168
|
const row = new BoxRenderable(this.renderer, {
|
|
143
169
|
width: INNER_W,
|
|
144
170
|
height: 1,
|
|
145
|
-
backgroundColor:
|
|
171
|
+
backgroundColor:
|
|
172
|
+
i === this.selectedIndex ? theme.bgTertiary : undefined,
|
|
146
173
|
});
|
|
147
|
-
row.add(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
row.add(
|
|
175
|
+
new TextRenderable(this.renderer, {
|
|
176
|
+
content: " ".repeat(MARGIN) + truncateName(file.name),
|
|
177
|
+
fg: i === this.selectedIndex ? theme.fgPrimary : theme.fgSecondary,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
151
180
|
container.add(row);
|
|
152
181
|
this.itemRows.push(row);
|
|
153
182
|
}
|
|
@@ -2,7 +2,7 @@ import { BoxRenderable, type CliRenderer, TextRenderable } from "@opentui/core";
|
|
|
2
2
|
import { getTheme } from "../theme";
|
|
3
3
|
import type { LayoutDimensions } from "../types/layout";
|
|
4
4
|
|
|
5
|
-
const DIALOG_WIDTH
|
|
5
|
+
const DIALOG_WIDTH = 38;
|
|
6
6
|
const DIALOG_HEIGHT = 7;
|
|
7
7
|
|
|
8
8
|
export class ConfirmDialog {
|
|
@@ -44,7 +44,13 @@ export class ConfirmDialog {
|
|
|
44
44
|
|
|
45
45
|
handleInput(key: string): boolean {
|
|
46
46
|
if (!this.isOpen) return false;
|
|
47
|
-
if (
|
|
47
|
+
if (
|
|
48
|
+
key === "tab" ||
|
|
49
|
+
key === "h" ||
|
|
50
|
+
key === "l" ||
|
|
51
|
+
key === "left" ||
|
|
52
|
+
key === "right"
|
|
53
|
+
) {
|
|
48
54
|
this.focusedBtn = this.focusedBtn === "confirm" ? "cancel" : "confirm";
|
|
49
55
|
this.updateButtons();
|
|
50
56
|
return true;
|
|
@@ -85,8 +91,14 @@ export class ConfirmDialog {
|
|
|
85
91
|
|
|
86
92
|
private build(message: string): void {
|
|
87
93
|
const theme = getTheme();
|
|
88
|
-
const left = Math.max(
|
|
89
|
-
|
|
94
|
+
const left = Math.max(
|
|
95
|
+
0,
|
|
96
|
+
Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2),
|
|
97
|
+
);
|
|
98
|
+
const top = Math.max(
|
|
99
|
+
0,
|
|
100
|
+
Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2),
|
|
101
|
+
);
|
|
90
102
|
|
|
91
103
|
const container = new BoxRenderable(this.renderer, {
|
|
92
104
|
position: "absolute",
|
|
@@ -101,17 +113,27 @@ export class ConfirmDialog {
|
|
|
101
113
|
|
|
102
114
|
const inner = DIALOG_WIDTH - 2;
|
|
103
115
|
|
|
104
|
-
container.add(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
container.add(
|
|
117
|
+
new TextRenderable(this.renderer, { content: " ".repeat(inner) }),
|
|
118
|
+
);
|
|
119
|
+
container.add(
|
|
120
|
+
new TextRenderable(this.renderer, {
|
|
121
|
+
content: ` ${message}`.padEnd(inner),
|
|
122
|
+
fg: theme.fgPrimary,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
container.add(
|
|
126
|
+
new TextRenderable(this.renderer, { content: " ".repeat(inner) }),
|
|
127
|
+
);
|
|
128
|
+
container.add(
|
|
129
|
+
new TextRenderable(this.renderer, {
|
|
130
|
+
content: " Files will be deleted from disk.".padEnd(inner),
|
|
131
|
+
fg: theme.fgSecondary,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
container.add(
|
|
135
|
+
new TextRenderable(this.renderer, { content: " ".repeat(inner) }),
|
|
136
|
+
);
|
|
115
137
|
|
|
116
138
|
const btnRow = new BoxRenderable(this.renderer, {
|
|
117
139
|
width: inner,
|
|
@@ -3,56 +3,95 @@ import type { Store } from "../store";
|
|
|
3
3
|
import { getTheme } from "../theme";
|
|
4
4
|
import type { LayoutDimensions } from "../types/layout";
|
|
5
5
|
import { filterTorrents } from "../utils/filter";
|
|
6
|
+
import { DetailPanel, type DetailTab } from "./detail-panel";
|
|
6
7
|
import { TorrentTable } from "./torrent-view";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
return {
|
|
10
|
-
...layout,
|
|
11
|
-
content: {
|
|
12
|
-
...layout.content,
|
|
13
|
-
width: layout.content.width - 2,
|
|
14
|
-
height: layout.content.height - 2,
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
}
|
|
9
|
+
export type FocusArea = "sidebar" | "table" | "details";
|
|
18
10
|
|
|
19
11
|
export class ContentWindow {
|
|
20
12
|
private renderer: CliRenderer;
|
|
21
13
|
private store: Store;
|
|
22
14
|
private layout: LayoutDimensions;
|
|
23
15
|
private container: BoxRenderable;
|
|
16
|
+
private tableFrame: BoxRenderable;
|
|
17
|
+
private tableInner!: BoxRenderable;
|
|
24
18
|
private torrentTable: TorrentTable;
|
|
19
|
+
private detailPanel: DetailPanel;
|
|
25
20
|
|
|
26
21
|
constructor(renderer: CliRenderer, store: Store, layout: LayoutDimensions) {
|
|
27
22
|
this.renderer = renderer;
|
|
28
23
|
this.store = store;
|
|
29
24
|
this.layout = layout;
|
|
30
|
-
this.torrentTable = new TorrentTable(renderer,
|
|
31
|
-
this.
|
|
25
|
+
this.torrentTable = new TorrentTable(renderer, tableLayout(layout));
|
|
26
|
+
this.detailPanel = new DetailPanel(renderer, detailLayout(layout));
|
|
27
|
+
const built = this.build();
|
|
28
|
+
this.container = built.container;
|
|
29
|
+
this.tableFrame = built.tableFrame;
|
|
32
30
|
this.renderer.root.add(this.container);
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
update(
|
|
33
|
+
update(
|
|
34
|
+
focusArea: FocusArea,
|
|
35
|
+
selectedIndex: number,
|
|
36
|
+
detailTab: DetailTab = "Pieces",
|
|
37
|
+
detailScrollOffset = 0,
|
|
38
|
+
): void {
|
|
36
39
|
const theme = getTheme();
|
|
37
40
|
const state = this.store.getState();
|
|
38
|
-
(this.
|
|
41
|
+
(this.tableFrame as unknown as { borderColor: string }).borderColor =
|
|
39
42
|
focusArea === "table" ? theme.accent : theme.border;
|
|
40
43
|
const visible = filterTorrents(state.torrents, state.selectedView);
|
|
41
44
|
this.torrentTable.update(visible, selectedIndex, focusArea);
|
|
45
|
+
const selectedTorrent =
|
|
46
|
+
focusArea === "sidebar" ? null : (visible[selectedIndex] ?? null);
|
|
47
|
+
const detailPlaceholder = resolveDetailPlaceholder(
|
|
48
|
+
focusArea,
|
|
49
|
+
state.torrents.length,
|
|
50
|
+
visible.length,
|
|
51
|
+
selectedTorrent !== null,
|
|
52
|
+
);
|
|
53
|
+
this.detailPanel.update(
|
|
54
|
+
selectedTorrent,
|
|
55
|
+
detailTab,
|
|
56
|
+
focusArea === "details",
|
|
57
|
+
detailScrollOffset,
|
|
58
|
+
detailPlaceholder,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getDetailBodyRowCount(): number {
|
|
63
|
+
return this.detailPanel.getBodyRowCount();
|
|
42
64
|
}
|
|
43
65
|
|
|
44
66
|
updateLayout(layout: LayoutDimensions): void {
|
|
45
67
|
this.layout = layout;
|
|
46
68
|
(this.container as unknown as { left: number }).left = layout.content.x;
|
|
47
69
|
(this.container as unknown as { top: number }).top = layout.content.y;
|
|
48
|
-
(this.container as unknown as { width: number }).width =
|
|
49
|
-
|
|
50
|
-
this.
|
|
70
|
+
(this.container as unknown as { width: number }).width =
|
|
71
|
+
layout.content.width;
|
|
72
|
+
(this.container as unknown as { height: number }).height =
|
|
73
|
+
layout.content.height;
|
|
74
|
+
|
|
75
|
+
const table = tableFrameLayout(layout);
|
|
76
|
+
(this.tableFrame as unknown as { left: number }).left = table.content.x;
|
|
77
|
+
(this.tableFrame as unknown as { top: number }).top = table.content.y;
|
|
78
|
+
(this.tableFrame as unknown as { width: number }).width =
|
|
79
|
+
table.content.width;
|
|
80
|
+
(this.tableFrame as unknown as { height: number }).height =
|
|
81
|
+
table.content.height;
|
|
82
|
+
(this.tableInner as unknown as { width: number }).width =
|
|
83
|
+
table.content.width - 2;
|
|
84
|
+
(this.tableInner as unknown as { height: number }).height =
|
|
85
|
+
table.content.height - 2;
|
|
86
|
+
|
|
87
|
+
this.torrentTable.updateLayout(tableLayout(layout));
|
|
88
|
+
this.detailPanel.updateLayout(detailLayout(layout));
|
|
51
89
|
}
|
|
52
90
|
|
|
53
|
-
private build(): BoxRenderable {
|
|
91
|
+
private build(): { container: BoxRenderable; tableFrame: BoxRenderable } {
|
|
54
92
|
const theme = getTheme();
|
|
55
93
|
const layout = this.layout;
|
|
94
|
+
const table = tableFrameLayout(layout);
|
|
56
95
|
|
|
57
96
|
const container = new BoxRenderable(this.renderer, {
|
|
58
97
|
position: "absolute",
|
|
@@ -60,21 +99,87 @@ export class ContentWindow {
|
|
|
60
99
|
top: layout.content.y,
|
|
61
100
|
width: layout.content.width,
|
|
62
101
|
height: layout.content.height,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const tableFrame = new BoxRenderable(this.renderer, {
|
|
105
|
+
position: "absolute",
|
|
106
|
+
left: table.content.x,
|
|
107
|
+
top: table.content.y,
|
|
108
|
+
width: table.content.width,
|
|
109
|
+
height: table.content.height,
|
|
63
110
|
border: true,
|
|
64
111
|
borderColor: theme.border,
|
|
65
112
|
});
|
|
66
113
|
|
|
67
|
-
|
|
68
|
-
const inner = new BoxRenderable(this.renderer, {
|
|
114
|
+
this.tableInner = new BoxRenderable(this.renderer, {
|
|
69
115
|
position: "absolute",
|
|
70
116
|
left: 1,
|
|
71
117
|
top: 1,
|
|
72
|
-
width:
|
|
73
|
-
height:
|
|
118
|
+
width: table.content.width - 2,
|
|
119
|
+
height: table.content.height - 2,
|
|
74
120
|
});
|
|
75
|
-
|
|
76
|
-
|
|
121
|
+
this.tableInner.add(this.torrentTable.getContainer());
|
|
122
|
+
tableFrame.add(this.tableInner);
|
|
123
|
+
|
|
124
|
+
container.add(tableFrame);
|
|
125
|
+
container.add(this.detailPanel.getContainer());
|
|
77
126
|
|
|
78
|
-
return container;
|
|
127
|
+
return { container, tableFrame };
|
|
79
128
|
}
|
|
80
129
|
}
|
|
130
|
+
|
|
131
|
+
function resolveDetailPlaceholder(
|
|
132
|
+
focusArea: FocusArea,
|
|
133
|
+
totalTorrents: number,
|
|
134
|
+
visibleTorrents: number,
|
|
135
|
+
hasSelectedTorrent: boolean,
|
|
136
|
+
): string | null {
|
|
137
|
+
if (hasSelectedTorrent) return null;
|
|
138
|
+
if (totalTorrents === 0) return "No torrents added yet";
|
|
139
|
+
if (visibleTorrents === 0) return "Select a torrent to inspect details";
|
|
140
|
+
if (focusArea === "sidebar") return "Select a torrent to inspect details";
|
|
141
|
+
return "No torrent selected";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function tableLayout(layout: LayoutDimensions): LayoutDimensions {
|
|
145
|
+
const frame = tableFrameLayout(layout);
|
|
146
|
+
return {
|
|
147
|
+
...layout,
|
|
148
|
+
content: {
|
|
149
|
+
x: 0,
|
|
150
|
+
y: 0,
|
|
151
|
+
width: frame.content.width - 2,
|
|
152
|
+
height: frame.content.height - 2,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function detailLayout(layout: LayoutDimensions): LayoutDimensions {
|
|
158
|
+
const detailHeight = getDetailHeight(layout.content.height);
|
|
159
|
+
return {
|
|
160
|
+
...layout,
|
|
161
|
+
content: {
|
|
162
|
+
x: 0,
|
|
163
|
+
y: layout.content.height - detailHeight,
|
|
164
|
+
width: layout.content.width,
|
|
165
|
+
height: detailHeight,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function tableFrameLayout(layout: LayoutDimensions): LayoutDimensions {
|
|
171
|
+
const detailHeight = getDetailHeight(layout.content.height);
|
|
172
|
+
return {
|
|
173
|
+
...layout,
|
|
174
|
+
content: {
|
|
175
|
+
x: 0,
|
|
176
|
+
y: 0,
|
|
177
|
+
width: layout.content.width,
|
|
178
|
+
height: Math.max(5, layout.content.height - detailHeight),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getDetailHeight(contentHeight: number): number {
|
|
184
|
+
return Math.max(6, Math.min(10, Math.floor(contentHeight * 0.35)));
|
|
185
|
+
}
|