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.
susops/tui/__init__.py ADDED
File without changes
susops/tui/__main__.py ADDED
@@ -0,0 +1,44 @@
1
+ """SusOps TUI / CLI entrypoint.
2
+
3
+ When stdout is a TTY and no subcommand is given: launch the Textual TUI.
4
+ Otherwise: dispatch the CLI command and exit with the appropriate code.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+
11
+ def main() -> None:
12
+ from susops.tui.cli import build_parser, dispatch
13
+
14
+ parser = build_parser()
15
+ args = parser.parse_args()
16
+
17
+ # Launch interactive TUI if:
18
+ # - running in a terminal (isatty)
19
+ # - no subcommand was given
20
+ if sys.stdout.isatty() and args.command is None:
21
+ try:
22
+ from susops.tui.app import SusOpsTuiApp
23
+ app = SusOpsTuiApp(verbose=args.verbose)
24
+ app.run()
25
+ except ImportError as exc:
26
+ print(
27
+ f"Error: Textual is not installed ({exc}).\n"
28
+ "Install with: pip install 'susops[tui]'\n"
29
+ "Or use a subcommand directly, e.g.: susops ps",
30
+ file=sys.stderr,
31
+ )
32
+ sys.exit(1)
33
+ elif args.command is None:
34
+ # Non-TTY with no subcommand: print status
35
+ args.command = "ps"
36
+ from susops.tui.cli import cmd_ps
37
+ args.func = cmd_ps
38
+ sys.exit(dispatch(args))
39
+ else:
40
+ sys.exit(dispatch(args))
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
susops/tui/app.py ADDED
@@ -0,0 +1,191 @@
1
+ """SusOps Textual TUI application."""
2
+ from __future__ import annotations
3
+
4
+ from textual.app import App, ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.command import Hit, Hits, Provider
7
+ from textual.containers import Horizontal
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Label, Static
10
+
11
+ from susops.tui.screens.connections import ConnectionsScreen
12
+ from susops.tui.screens.dashboard import DashboardScreen
13
+ from susops.tui.screens.shares import SharesScreen
14
+
15
+
16
+ class _SusOpsCommands(Provider):
17
+ """Command palette provider for quick actions."""
18
+
19
+ async def search(self, query: str) -> Hits:
20
+ app: SusOpsTuiApp = self.app # type: ignore[assignment]
21
+ commands = [
22
+ ("Start tunnels", app.action_start_all, "Start all SSH tunnels"),
23
+ ("Stop tunnels", app.action_stop_all, "Stop all SSH tunnels"),
24
+ ("Restart tunnels", app.action_restart_all, "Restart all SSH tunnels"),
25
+ ("Dashboard", lambda: app.push_screen("dashboard"), "Go to dashboard"),
26
+ ("Connections", lambda: app.push_screen("connections"), "Manage connections"),
27
+ ("Shares", lambda: app.push_screen("shares"), "Share/fetch files"),
28
+ ("Launch Browser", app.action_launch_browser, "Open a browser with the daemon's PAC URL"),
29
+ ("Open Proxy Settings", app.action_launch_browser, "chrome://net-internals/#proxy in a chosen browser"),
30
+ ("Config", lambda: app.action_show_config(), "View config.yaml"),
31
+ ("PAC file", lambda: app.action_show_pac(), "View PAC proxy config"),
32
+ ("Quit", app.action_quit, "Quit SusOps"),
33
+ ]
34
+ q = query.lower()
35
+ for name, action, description in commands:
36
+ if q in name.lower() or q in description.lower():
37
+ yield Hit(
38
+ score=1.0,
39
+ match_display=name,
40
+ command=action,
41
+ help=description,
42
+ )
43
+
44
+
45
+ class _ConfigErrorScreen(ModalScreen):
46
+ """Shown when config.yaml fails to load. Allows the user to open $EDITOR to fix it."""
47
+
48
+ def __init__(self, error: str, config_path: str) -> None:
49
+ super().__init__()
50
+ self._error = error
51
+ self._config_path = config_path
52
+
53
+ def compose(self) -> ComposeResult:
54
+ with Static(classes="modal-dialog"):
55
+ yield Label("[bold red]Config file error[/bold red]")
56
+ yield Label(f"[dim]{self._config_path}[/dim]")
57
+ yield Label(self._error)
58
+ yield Label("\nFix the file and restart susops, or press [bold]e[/bold] to open in $EDITOR.")
59
+ with Horizontal(classes="modal-btn-row"):
60
+ yield Button("Open in $EDITOR", id="btn-edit", variant="warning")
61
+ yield Button("Quit", id="btn-quit", variant="error")
62
+
63
+ def on_button_pressed(self, event) -> None:
64
+ import os
65
+ import subprocess
66
+ if event.button.id == "btn-edit":
67
+ editor = os.environ.get("EDITOR", "nano")
68
+ subprocess.run([editor, self._config_path])
69
+ self.dismiss(None)
70
+ else:
71
+ self.app.exit(1)
72
+
73
+
74
+ class SusOpsTuiApp(App):
75
+ """SusOps Textual TUI — SSH tunnel + PAC proxy manager."""
76
+
77
+ TITLE = "SusOps"
78
+
79
+ @property
80
+ def SUB_TITLE(self) -> str: # type: ignore[override]
81
+ import susops
82
+ return f"SSH Tunnel & PAC Manager v{susops.__version__}"
83
+
84
+ CSS_PATH = "app.tcss"
85
+
86
+ BINDINGS = [
87
+ Binding("c", "push_screen('connections')", "Connections", show=False),
88
+ Binding("f", "push_screen('shares')", "Shares", show=False),
89
+ Binding("b", "launch_browser", "Browser", show=False),
90
+ Binding("s", "start_all", "Start", show=False),
91
+ Binding("x", "stop_all", "Stop", show=False),
92
+ Binding("r", "restart_all", "Restart", show=False),
93
+ Binding("ctrl+p", "command_palette", "Commands"),
94
+ Binding("q", "quit", "Quit"),
95
+ ]
96
+
97
+ SCREENS = {
98
+ "dashboard": DashboardScreen,
99
+ "connections": ConnectionsScreen,
100
+ "shares": SharesScreen,
101
+ }
102
+
103
+ COMMANDS = App.COMMANDS | {_SusOpsCommands}
104
+
105
+ def __init__(self, verbose: bool = False) -> None:
106
+ super().__init__()
107
+ self._verbose = verbose
108
+ self.manager = None # type: ignore[assignment]
109
+
110
+ def on_mount(self) -> None:
111
+ from pathlib import Path
112
+ from susops.client import SusOpsClient
113
+ workspace = Path.home() / ".susops"
114
+ try:
115
+ self.manager = SusOpsClient(workspace=workspace)
116
+ except Exception as exc:
117
+ config_path = str(workspace / "config.yaml")
118
+ self.push_screen(_ConfigErrorScreen(str(exc), config_path))
119
+ return
120
+ self.push_screen("dashboard")
121
+
122
+ def action_quit(self) -> None:
123
+ if self.manager.app_config.stop_on_quit:
124
+ # Stops SSH tunnels + share servers in the daemon; PAC stays up
125
+ # via stopped-marker tombstones unless the user reset.
126
+ self.manager.stop_quick()
127
+ # No detach calls — the daemon is already a separate process and
128
+ # outlives the TUI.
129
+ self.exit()
130
+
131
+ def action_start_all(self) -> None:
132
+ self._bg_start()
133
+
134
+ def action_stop_all(self) -> None:
135
+ self._bg_stop()
136
+
137
+ def action_restart_all(self) -> None:
138
+ self._bg_restart()
139
+
140
+ def action_open_github(self) -> None:
141
+ import webbrowser
142
+ webbrowser.open("https://github.com/mashb1t/susops")
143
+
144
+ def action_show_config(self) -> None:
145
+ from susops.tui.screens.dashboard import DashboardScreen
146
+ screen = self.query_one(DashboardScreen)
147
+ screen.action_show_tab("tab-config")
148
+
149
+ def action_show_pac(self) -> None:
150
+ from susops.tui.screens.dashboard import DashboardScreen
151
+ screen = self.query_one(DashboardScreen)
152
+ screen.action_show_tab("tab-pac")
153
+
154
+ def action_launch_browser(self) -> None:
155
+ """Open the modal browser picker. Works from any screen via global `b`
156
+ binding or the Ctrl+P command palette."""
157
+ from susops.tui.screens.dashboard import BrowserScreen
158
+ self.push_screen(BrowserScreen())
159
+
160
+ def _bg_start(self) -> None:
161
+ self.run_worker(self._do_start, thread=True)
162
+
163
+ def _bg_stop(self) -> None:
164
+ self.run_worker(self._do_stop, thread=True)
165
+
166
+ def _bg_restart(self) -> None:
167
+ self.run_worker(self._do_restart, thread=True)
168
+
169
+ def _do_start(self) -> None:
170
+ result = self.manager.start()
171
+ self.call_from_thread(
172
+ self.notify,
173
+ result.message,
174
+ severity="information" if result.success else "error",
175
+ )
176
+
177
+ def _do_stop(self) -> None:
178
+ result = self.manager.stop()
179
+ self.call_from_thread(
180
+ self.notify,
181
+ result.message,
182
+ severity="information" if result.success else "error",
183
+ )
184
+
185
+ def _do_restart(self) -> None:
186
+ result = self.manager.restart()
187
+ self.call_from_thread(
188
+ self.notify,
189
+ result.message,
190
+ severity="information" if result.success else "error",
191
+ )