susops 3.0.0rc3.dev1__py3-none-any.whl

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.
@@ -0,0 +1,357 @@
1
+ """Share screen — split-pane share list with modal dialogs for add/fetch."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from textual import work
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.css.query import NoMatches
11
+ from textual.screen import ModalScreen, Screen
12
+ from textual.widgets import Button, Input, Label, ListItem, ListView, Select, Static
13
+
14
+ from susops.core.ports import is_port_free, validate_port
15
+ from susops.tui.screens import _CollapsingLabel, compose_footer, open_in_explorer, share_status_dot
16
+
17
+
18
+ class _AddShareDialog(ModalScreen):
19
+ """Modal: start sharing a file."""
20
+
21
+ def __init__(self, conn_hosts: dict[str, str], **kwargs) -> None:
22
+ super().__init__(**kwargs)
23
+ self._conn_hosts = conn_hosts # tag -> ssh_host
24
+
25
+ def compose(self) -> ComposeResult:
26
+ options = [(tag, tag) for tag in self._conn_hosts]
27
+ with Static(classes="modal-dialog"):
28
+ yield Label("[bold]Share a file[/bold]")
29
+ yield Label("File path:")
30
+ yield Input(placeholder="/path/to/file", id="path")
31
+ with Horizontal(classes="modal-form-row"):
32
+ with Static(classes="modal-field"):
33
+ yield Label("Password (blank = auto-generate):")
34
+ yield Input(placeholder="", id="password")
35
+ with Static(classes="modal-field"):
36
+ yield Label("Port (0 = auto):")
37
+ yield Input(placeholder="0", value="0", id="port")
38
+ yield Label("Connection:")
39
+ yield Select(options, allow_blank=False, id="conn")
40
+ yield _CollapsingLabel("", id="error", classes="modal-error")
41
+ with Horizontal(classes="modal-btn-row"):
42
+ yield Button("Share", id="btn-ok", variant="success")
43
+ yield Button("Cancel", id="btn-cancel")
44
+
45
+ def on_button_pressed(self, event: Button.Pressed) -> None:
46
+ if event.button.id == "btn-cancel":
47
+ self.dismiss(None)
48
+ return
49
+ path = self.query_one("#path", Input).value.strip()
50
+ if not path:
51
+ self.query_one("#error", Label).update("File path is required.")
52
+ return
53
+ if not Path(path).exists():
54
+ self.query_one("#error", Label).update("File not found.")
55
+ return
56
+ pw = self.query_one("#password", Input).value.strip() or None
57
+ error_label = self.query_one("#error", Label)
58
+ try:
59
+ port = int(self.query_one("#port", Input).value.strip() or "0")
60
+ except ValueError:
61
+ error_label.update("Port must be a number.")
62
+ return
63
+ if not validate_port(port, allow_zero=True):
64
+ error_label.update("Port must be 0 (auto) or between 1 and 65535.")
65
+ return
66
+ if port != 0 and not is_port_free(port):
67
+ error_label.update(f"Port {port} is already in use.")
68
+ return
69
+ conn_val = self.query_one("#conn", Select).value
70
+ conn = conn_val if isinstance(conn_val, str) else None
71
+ self.dismiss({"path": path, "password": pw, "port": port, "conn": conn})
72
+
73
+
74
+ class _FetchDialog(ModalScreen):
75
+ """Modal: fetch a shared file.
76
+
77
+ Performs the download inside the dialog so the user can see progress,
78
+ retry with corrected inputs on error, and only leaves when the fetch
79
+ succeeds (or they cancel). Dismisses with the downloaded Path on success.
80
+ """
81
+
82
+ def __init__(self, conn_hosts: dict[str, str], **kwargs) -> None:
83
+ super().__init__(**kwargs)
84
+ self._conn_hosts = conn_hosts # tag -> ssh_host
85
+
86
+ def compose(self) -> ComposeResult:
87
+ options = [(tag, tag) for tag in self._conn_hosts]
88
+ with Static(classes="modal-dialog"):
89
+ yield Label("[bold]Fetch a shared file[/bold]")
90
+ with Horizontal(classes="modal-form-row"):
91
+ with Static(classes="modal-field"):
92
+ yield Label("Connection:")
93
+ yield Select(options, allow_blank=False, id="conn")
94
+ with Static(classes="modal-field"):
95
+ yield Label("Port:")
96
+ yield Input(placeholder="52100", id="port")
97
+ yield Label("Password:")
98
+ yield Input(placeholder="", id="password")
99
+ yield Label("Save to (blank = ~/Downloads/<filename>):")
100
+ yield Input(placeholder="", id="outfile")
101
+ yield _CollapsingLabel("", id="error", classes="modal-error")
102
+ with Horizontal(classes="modal-btn-row"):
103
+ yield Button("Fetch", id="btn-ok", variant="primary")
104
+ yield Button("Cancel", id="btn-cancel")
105
+
106
+ def on_button_pressed(self, event: Button.Pressed) -> None:
107
+ if event.button.id == "btn-cancel":
108
+ self.dismiss(None)
109
+ return
110
+ port_str = self.query_one("#port", Input).value.strip()
111
+ pw = self.query_one("#password", Input).value.strip()
112
+ error_label = self.query_one("#error", Label)
113
+ if not port_str or not pw:
114
+ error_label.update("Port and password are required.")
115
+ return
116
+ try:
117
+ port = int(port_str)
118
+ except ValueError:
119
+ error_label.update("Port must be a number.")
120
+ return
121
+ if not validate_port(port):
122
+ error_label.update("Port must be between 1 and 65535.")
123
+ return
124
+ conn_val = self.query_one("#conn", Select).value
125
+ conn = conn_val if isinstance(conn_val, str) else None
126
+ if not conn:
127
+ error_label.update("Connection is required.")
128
+ return
129
+ outfile = self.query_one("#outfile", Input).value.strip() or None
130
+ self._start_fetch(port, pw, conn, outfile)
131
+
132
+ def _set_busy(self, busy: bool) -> None:
133
+ btn = self.query_one("#btn-ok", Button)
134
+ btn.disabled = busy
135
+ btn.label = "Fetching…" if busy else "Fetch"
136
+ for widget_id in ("#port", "#password", "#outfile"):
137
+ self.query_one(widget_id, Input).disabled = busy
138
+ self.query_one("#conn", Select).disabled = busy
139
+
140
+ @work(thread=True)
141
+ def _start_fetch(self, port: int, password: str, conn: str, outfile: str | None) -> None:
142
+ self.app.call_from_thread(self._set_busy, True)
143
+ self.app.call_from_thread(self.query_one("#error", Label).update, "[dim]Fetching…[/dim]")
144
+ try:
145
+ out = Path(outfile) if outfile else None
146
+ result = self.app.manager.fetch( # type: ignore[attr-defined]
147
+ port=port, password=password, conn_tag=conn, outfile=out
148
+ )
149
+ self.app.call_from_thread(self.dismiss, result)
150
+ except Exception as e:
151
+ self.app.call_from_thread(self._set_busy, False)
152
+ self.app.call_from_thread(self.query_one("#error", Label).update, f"[red]{e}[/red]")
153
+
154
+
155
+ class SharesScreen(Screen):
156
+ """Split-pane share screen: active shares list + detail panel."""
157
+
158
+ BINDINGS = [
159
+ Binding("escape", "app.pop_screen", "Back"),
160
+ Binding("a", "add_share", "Add"),
161
+ Binding("f", "fetch_file", "Fetch"),
162
+ Binding("d", "stop_share", "Stop"),
163
+ Binding("s", "start_share", "Start"),
164
+ Binding("x", "delete_share", "Delete"),
165
+ ]
166
+
167
+ def compose(self) -> ComposeResult:
168
+ with Horizontal(id="share-split"):
169
+ with Vertical(id="share-list-panel"):
170
+ yield ListView(id="share-list")
171
+ yield Static("", id="share-detail", markup=True)
172
+ yield Label("", id="share-status")
173
+ yield from compose_footer()
174
+
175
+ def on_mount(self) -> None:
176
+ lv = self.query_one("#share-list", ListView)
177
+ lv.border_title = "Shares"
178
+ self._shares: list = []
179
+ self._reload()
180
+ self.set_interval(2.0, self._reload)
181
+
182
+ def _reload(self) -> None:
183
+ new_shares = self.app.manager.list_shares() # type: ignore[attr-defined]
184
+ lv = self.query_one("#share-list", ListView)
185
+
186
+ def _label(info) -> str:
187
+ name = Path(info.file_path).name
188
+ dot = share_status_dot(info.running, info.stopped)
189
+ return f"{dot} {name} {info.port}"
190
+
191
+ new_ports = [i.port for i in new_shares]
192
+ old_ports = [i.port for i in self._shares]
193
+
194
+ if new_ports == old_ports:
195
+ # Same shares — update labels in-place so selection is never disturbed
196
+ for item, info in zip(lv.query(ListItem), new_shares):
197
+ # ListItem.compose moves the Label from _pending_children to
198
+ # the DOM. If a recent rebuild's mount hasn't fired yet we
199
+ # land here with no Label child — skip; the next tick retries.
200
+ try:
201
+ item.query_one(Label).update(_label(info))
202
+ except NoMatches:
203
+ pass
204
+ else:
205
+ # Shares added/removed — rebuild and restore cursor
206
+ cur = lv.index or 0
207
+ lv.clear()
208
+ for info in new_shares:
209
+ lv.append(ListItem(Label(_label(info))))
210
+ if new_shares:
211
+ lv.index = min(cur, len(new_shares) - 1)
212
+
213
+ self._shares = new_shares
214
+
215
+ n_running = sum(1 for i in self._shares if i.running)
216
+ n_stopped = sum(1 for i in self._shares if not i.running and i.stopped)
217
+ n_offline = sum(1 for i in self._shares if not i.running and not i.stopped)
218
+ count = len(self._shares)
219
+ if count == 0:
220
+ status = "No shares"
221
+ elif n_running == count:
222
+ status = f"{count} share(s) running"
223
+ else:
224
+ parts = []
225
+ if n_running:
226
+ parts.append(f"{n_running} running")
227
+ if n_stopped:
228
+ parts.append(f"{n_stopped} stopped")
229
+ if n_offline:
230
+ parts.append(f"{n_offline} offline")
231
+ status = f"{count} shares ({', '.join(parts)})"
232
+
233
+ self.query_one("#share-status", Label).update(status)
234
+ if self._shares:
235
+ idx = lv.index or 0
236
+ self._show_detail(self._shares[idx])
237
+ else:
238
+ self.query_one("#share-detail", Static).update(
239
+ "[dim]No shares.\n\nPress [bold]a[/bold] to share a file.[/dim]"
240
+ )
241
+
242
+ def _conn_hosts(self) -> dict[str, str]:
243
+ try:
244
+ cfg = self.app.manager.list_config() # type: ignore[attr-defined]
245
+ return {c.tag: c.ssh_host for c in cfg.connections}
246
+ except Exception:
247
+ return {}
248
+
249
+ def _show_detail(self, info) -> None:
250
+ name = Path(info.file_path).name
251
+ if info.running:
252
+ state_str = "[green]running[/green]"
253
+ elif info.stopped:
254
+ state_str = "[dim]stopped[/dim]"
255
+ else:
256
+ state_str = "[red]offline[/red]"
257
+
258
+ if "'" not in info.file_path:
259
+ file_display = f"[@click=screen.open_share('{info.file_path}')]{info.file_path}[/]"
260
+ else:
261
+ file_display = info.file_path
262
+ text = (
263
+ f"[bold]File:[/bold] {file_display}\n"
264
+ f"[bold]Name:[/bold] {name}\n"
265
+ f"[bold]Connection:[/bold] {info.conn_tag or '—'}\n"
266
+ f"[bold]Port:[/bold] {info.port}\n"
267
+ f"[bold]Password:[/bold] {info.password}\n"
268
+ )
269
+ if info.running:
270
+ access_str = f"[green]{info.access_count} ok[/green]"
271
+ if info.failed_count:
272
+ access_str += f" [red]{info.failed_count} failed[/red]"
273
+ text += (
274
+ f"[bold]URL:[/bold] {info.url}\n"
275
+ f"[bold]State:[/bold] {state_str}\n"
276
+ f"[bold]Access:[/bold] {access_str}\n"
277
+ )
278
+ else:
279
+ text += f"[bold]State:[/bold] {state_str}\n"
280
+
281
+ if info.running:
282
+ text += (
283
+ f"\n[bold]Fetch command:[/bold]\n"
284
+ f" [dim]susops -c {info.conn_tag} fetch {info.port} {info.password}[/dim]"
285
+ f"\n\n[dim]Press [bold]d[/bold] to stop · [bold]x[/bold] to delete[/dim]"
286
+ )
287
+ elif info.stopped:
288
+ text += "\n[dim]Press [bold]s[/bold] to restart · [bold]x[/bold] to delete[/dim]"
289
+ else:
290
+ text += "\n[dim]Will auto-resume when connection starts · Press [bold]d[/bold] to stop · [bold]x[/bold] to delete[/dim]"
291
+ self.query_one("#share-detail", Static).update(text)
292
+
293
+ def action_open_share(self, file_path: str) -> None:
294
+ open_in_explorer(file_path)
295
+
296
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
297
+ idx = event.list_view.index
298
+ if idx is not None and 0 <= idx < len(self._shares):
299
+ self._show_detail(self._shares[idx])
300
+
301
+ def action_add_share(self) -> None:
302
+ def _on_result(data) -> None:
303
+ if not data:
304
+ return
305
+ self._do_share(data["path"], data["conn"], data["password"], data["port"])
306
+
307
+ self.app.push_screen(_AddShareDialog(self._conn_hosts()), _on_result)
308
+
309
+ def action_fetch_file(self) -> None:
310
+ def _on_result(result) -> None:
311
+ if result is None:
312
+ return
313
+ self.query_one("#share-status", Label).update(
314
+ f"[green]Downloaded to: {result}[/green]"
315
+ )
316
+
317
+ self.app.push_screen(_FetchDialog(self._conn_hosts()), _on_result)
318
+
319
+ def action_stop_share(self) -> None:
320
+ idx = self.query_one("#share-list", ListView).index
321
+ if idx is None or idx >= len(self._shares):
322
+ return
323
+ port = self._shares[idx].port
324
+ self.app.manager.stop_share(port) # type: ignore[attr-defined]
325
+ self._reload()
326
+
327
+ def action_delete_share(self) -> None:
328
+ idx = self.query_one("#share-list", ListView).index
329
+ if idx is None or idx >= len(self._shares):
330
+ return
331
+ port = self._shares[idx].port
332
+ self.app.manager.delete_share(port) # type: ignore[attr-defined]
333
+ self._reload()
334
+
335
+ def action_start_share(self) -> None:
336
+ """Restart a stopped share."""
337
+ idx = self.query_one("#share-list", ListView).index
338
+ if idx is None or idx >= len(self._shares):
339
+ return
340
+ info = self._shares[idx]
341
+ if info.running:
342
+ return
343
+ self._do_share(info.file_path, info.conn_tag, info.password, info.port)
344
+
345
+ def action_refresh(self) -> None:
346
+ self._reload()
347
+
348
+ @work(thread=True)
349
+ def _do_share(self, path: str, conn_tag: str, password: str | None, port: int) -> None:
350
+ mgr = self.app.manager # type: ignore[attr-defined]
351
+ try:
352
+ info = mgr.share(Path(path), conn_tag, password=password, port=port or None)
353
+ msg = f"[green]Sharing {Path(path).name} on port {info.port}, pw: {info.password}[/green]"
354
+ except Exception as e:
355
+ msg = f"[red]Error: {e}[/red]"
356
+ self.app.call_from_thread(self.query_one("#share-status", Label).update, msg)
357
+ self.app.call_from_thread(self._reload)
File without changes
@@ -0,0 +1,137 @@
1
+ """ConnectionCard widget — htop-style per-connection status card."""
2
+ from __future__ import annotations
3
+
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Horizontal
6
+ from textual.reactive import reactive
7
+ from textual.widgets import Label, Sparkline, Static
8
+
9
+ from susops.core.types import ConnectionStatus
10
+
11
+
12
+ def _fmt_bps(bps: float) -> str:
13
+ if bps >= 1_048_576:
14
+ return f"{bps / 1_048_576:.1f} MB/s"
15
+ if bps >= 1024:
16
+ return f"{bps / 1024:.0f} kB/s"
17
+ return f"{bps:.0f} B/s"
18
+
19
+
20
+ class ConnectionCard(Static):
21
+ """Htop-style card: status dot, SSH info, CPU/mem/conns, bandwidth sparklines, forwards."""
22
+
23
+ DEFAULT_CSS = """
24
+ ConnectionCard {
25
+ height: auto;
26
+ border: round $surface-darken-1;
27
+ padding: 0 1;
28
+ margin: 0 0 1 0;
29
+ }
30
+ ConnectionCard .row-title {
31
+ height: 1;
32
+ text-style: bold;
33
+ }
34
+ ConnectionCard .row-sys {
35
+ height: 1;
36
+ color: $text-muted;
37
+ }
38
+ ConnectionCard .row-fwd {
39
+ height: 1;
40
+ color: $text-muted;
41
+ }
42
+ ConnectionCard .bw-label {
43
+ height: 1;
44
+ color: $text-muted;
45
+ width: 18;
46
+ }
47
+ ConnectionCard Sparkline {
48
+ height: 3;
49
+ }
50
+ """
51
+
52
+ _MAX_SAMPLES = 40
53
+
54
+ _title: reactive[str] = reactive("")
55
+ _sys_info: reactive[str] = reactive("")
56
+ _fwd_info: reactive[str] = reactive("")
57
+ _rx_label: reactive[str] = reactive("↓ 0 B/s")
58
+ _tx_label: reactive[str] = reactive("↑ 0 B/s")
59
+
60
+ def __init__(self, tag: str, **kwargs) -> None:
61
+ super().__init__(**kwargs)
62
+ self.tag = tag
63
+ self._rx_data: list[float] = [0.0] * self._MAX_SAMPLES
64
+ self._tx_data: list[float] = [0.0] * self._MAX_SAMPLES
65
+
66
+ def compose(self) -> ComposeResult:
67
+ yield Label(self._title, id=f"title-{self.tag}", classes="row-title")
68
+ yield Label(self._sys_info, id=f"sys-{self.tag}", classes="row-sys")
69
+ with Horizontal():
70
+ yield Label(self._rx_label, id=f"rxlbl-{self.tag}", classes="bw-label")
71
+ yield Sparkline(self._rx_data, id=f"rx-{self.tag}", summary_function=max)
72
+ with Horizontal():
73
+ yield Label(self._tx_label, id=f"txlbl-{self.tag}", classes="bw-label")
74
+ yield Sparkline(self._tx_data, id=f"tx-{self.tag}", summary_function=max)
75
+ yield Label(self._fwd_info, id=f"fwd-{self.tag}", classes="row-fwd")
76
+
77
+ def _safe_update(self, widget_id: str, cls, value) -> None:
78
+ try:
79
+ self.query_one(widget_id, cls).update(value)
80
+ except Exception:
81
+ pass
82
+
83
+ def watch__title(self, v: str) -> None:
84
+ self._safe_update(f"#title-{self.tag}", Label, v)
85
+
86
+ def watch__sys_info(self, v: str) -> None:
87
+ self._safe_update(f"#sys-{self.tag}", Label, v)
88
+
89
+ def watch__fwd_info(self, v: str) -> None:
90
+ self._safe_update(f"#fwd-{self.tag}", Label, v)
91
+
92
+ def watch__rx_label(self, v: str) -> None:
93
+ self._safe_update(f"#rxlbl-{self.tag}", Label, v)
94
+
95
+ def watch__tx_label(self, v: str) -> None:
96
+ self._safe_update(f"#txlbl-{self.tag}", Label, v)
97
+
98
+ def refresh_status(self, status: ConnectionStatus, proc_info: dict | None = None,
99
+ forwards: list | None = None) -> None:
100
+ dot_color = "green" if status.running else "red"
101
+ dot = f"[{dot_color}]●[/{dot_color}]"
102
+ port_str = f" SOCKS {status.socks_port}" if status.socks_port else ""
103
+ pid_str = f" pid={status.pid}" if status.pid else ""
104
+ self._title = f"{dot} [bold]{status.tag}[/bold]{port_str}{pid_str}"
105
+
106
+ if proc_info and status.running:
107
+ cpu = proc_info.get("cpu", 0.0)
108
+ mem = proc_info.get("mem_mb", 0.0)
109
+ conns = proc_info.get("conns", 0)
110
+ self._sys_info = (
111
+ f"[dim]CPU: {cpu:.1f}% MEM: {mem:.1f} MB "
112
+ f"Active conns: {conns}[/dim]"
113
+ )
114
+ elif not status.running:
115
+ self._sys_info = "[dim]stopped[/dim]"
116
+ else:
117
+ self._sys_info = ""
118
+
119
+ if forwards is not None:
120
+ parts = []
121
+ for fw in forwards:
122
+ label = f" [{fw.tag}]" if fw.tag else ""
123
+ parts.append(f"{fw.src_port}→{fw.dst_addr}:{fw.dst_port}{label}")
124
+ self._fwd_info = (
125
+ f"[dim]Forwards: {', '.join(parts)}[/dim]" if parts else ""
126
+ )
127
+
128
+ def refresh_bandwidth(self, rx: float, tx: float) -> None:
129
+ self._rx_data = self._rx_data[1:] + [rx]
130
+ self._tx_data = self._tx_data[1:] + [tx]
131
+ try:
132
+ self.query_one(f"#rx-{self.tag}", Sparkline).data = self._rx_data
133
+ self.query_one(f"#tx-{self.tag}", Sparkline).data = self._tx_data
134
+ except Exception:
135
+ pass
136
+ self._rx_label = f"↓ {_fmt_bps(rx):>10}"
137
+ self._tx_label = f"↑ {_fmt_bps(tx):>10}"
susops/version.py ADDED
@@ -0,0 +1,12 @@
1
+ """Single source of truth for the susops package version."""
2
+ from importlib.metadata import version, PackageNotFoundError
3
+
4
+ try:
5
+ VERSION = version("susops")
6
+ except PackageNotFoundError:
7
+ # Running from source without installation — fall back to pyproject.toml
8
+ import tomllib
9
+ from pathlib import Path
10
+
11
+ _pyproject = Path(__file__).parent.parent.parent / "pyproject.toml"
12
+ VERSION = tomllib.loads(_pyproject.read_text())["project"]["version"]