graphical-ssh 0.1.1__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.
gssh/main_window.py ADDED
@@ -0,0 +1,384 @@
1
+ """Main application window — host list, editor, and connection management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal
8
+ from PyQt6.QtWidgets import (
9
+ QDockWidget,
10
+ QHBoxLayout,
11
+ QLabel,
12
+ QLineEdit,
13
+ QListWidget,
14
+ QListWidgetItem,
15
+ QMainWindow,
16
+ QMessageBox,
17
+ QPushButton,
18
+ QSplitter,
19
+ QTableView,
20
+ QToolBar,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from .config import HostEntry, SSHConfig, load_config, save_config
26
+ from .connect import build_ssh_command, launch_external, launch_in_app, test_connection
27
+ from .host_editor import HostEditorDialog
28
+ from .linter import LintFinding, lint_config, severity_icon
29
+ from .models import HostTableModel
30
+
31
+
32
+ class _ConnectionTestWorker(QThread):
33
+ """Runs a connection test in a background thread."""
34
+
35
+ finished = pyqtSignal(object, object) # (host_alias, ConnectionTestResult)
36
+
37
+ def __init__(self, host: HostEntry, parent=None) -> None:
38
+ super().__init__(parent)
39
+ self._host = host
40
+
41
+ def run(self) -> None:
42
+ result = test_connection(self._host)
43
+ self.finished.emit(self._host.alias, result)
44
+
45
+
46
+ class MainWindow(QMainWindow):
47
+ """The main gssh window."""
48
+
49
+ def __init__(self, config_path: Path | None = None) -> None:
50
+ super().__init__()
51
+ self.setWindowTitle("gssh — SSH Connection Manager")
52
+ self.setMinimumSize(900, 600)
53
+
54
+ self._config_path = config_path
55
+ self._config: SSHConfig = SSHConfig()
56
+ self._terminal_widget = None # type: ignore
57
+ self._test_worker = None # type: ignore
58
+ self._lint_findings: list[LintFinding] = []
59
+
60
+ self._build_ui()
61
+ self._load_config()
62
+
63
+ def _build_ui(self) -> None:
64
+ # Central widget: splitter with host table (left) and terminal area (right)
65
+ central = QWidget()
66
+ layout = QVBoxLayout(central)
67
+ layout.setContentsMargins(0, 0, 0, 0)
68
+
69
+ splitter = QSplitter(Qt.Orientation.Horizontal)
70
+
71
+ # Left: host table
72
+ left_panel = QWidget()
73
+ left_layout = QVBoxLayout(left_panel)
74
+ left_layout.setContentsMargins(4, 4, 4, 4)
75
+
76
+ # Search bar
77
+ search_layout = QHBoxLayout()
78
+ self.search_edit = QLineEdit()
79
+ self.search_edit.setPlaceholderText("Filter hosts…")
80
+ self.search_edit.textChanged.connect(self._filter_hosts)
81
+ search_layout.addWidget(self.search_edit)
82
+ left_layout.addLayout(search_layout)
83
+
84
+ # Table
85
+ self.table_model = HostTableModel()
86
+ self.table = QTableView()
87
+ self.table.setModel(self.table_model)
88
+ self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
89
+ self.table.setSelectionMode(QTableView.SelectionMode.SingleSelection)
90
+ self.table.setAlternatingRowColors(True)
91
+ self.table.doubleClicked.connect(self._on_connect)
92
+ left_layout.addWidget(self.table)
93
+
94
+ splitter.addWidget(left_panel)
95
+
96
+ # Right: terminal container (starts empty)
97
+ self.terminal_container = QWidget()
98
+ self.terminal_layout = QVBoxLayout(self.terminal_container)
99
+ self.terminal_layout.setContentsMargins(0, 0, 0, 0)
100
+ self.terminal_placeholder = QLabel("Select a host and click Connect to open a terminal session.")
101
+ self.terminal_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
102
+ self.terminal_placeholder.setStyleSheet("color: #888; font-size: 14px;")
103
+ self.terminal_layout.addWidget(self.terminal_placeholder)
104
+ splitter.addWidget(self.terminal_container)
105
+
106
+ splitter.setSizes([350, 550])
107
+ layout.addWidget(splitter)
108
+ self.setCentralWidget(central)
109
+
110
+ # Toolbar
111
+ self._build_toolbar()
112
+
113
+ # Lint dock widget
114
+ self._lint_dock = QDockWidget("Config Warnings", self)
115
+ self._lint_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
116
+ self._lint_list = QListWidget()
117
+ self._lint_list.itemClicked.connect(self._on_lint_item_clicked)
118
+ self._lint_dock.setWidget(self._lint_list)
119
+ self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self._lint_dock)
120
+ self._lint_dock.hide()
121
+
122
+ # Status bar
123
+ self.statusBar().showMessage("Ready")
124
+
125
+ def _build_toolbar(self) -> None:
126
+ toolbar = QToolBar("Main")
127
+ toolbar.setMovable(False)
128
+ self.addToolBar(toolbar)
129
+
130
+ btn_add = QPushButton("Add Host")
131
+ btn_add.clicked.connect(self._on_add_host)
132
+ toolbar.addWidget(btn_add)
133
+
134
+ btn_edit = QPushButton("Edit")
135
+ btn_edit.clicked.connect(self._on_edit_host)
136
+ toolbar.addWidget(btn_edit)
137
+
138
+ btn_delete = QPushButton("Delete")
139
+ btn_delete.clicked.connect(self._on_delete_host)
140
+ toolbar.addWidget(btn_delete)
141
+
142
+ toolbar.addSeparator()
143
+
144
+ self.btn_connect = QPushButton("Connect")
145
+ self.btn_connect.clicked.connect(self._on_connect_clicked)
146
+ toolbar.addWidget(self.btn_connect)
147
+
148
+ btn_test = QPushButton("Test")
149
+ btn_test.clicked.connect(self._on_test_connection)
150
+ toolbar.addWidget(btn_test)
151
+
152
+ toolbar.addSeparator()
153
+
154
+ # Terminal mode toggle
155
+ self.btn_inapp = QPushButton("In-App Terminal")
156
+ self.btn_inapp.setCheckable(True)
157
+ self.btn_inapp.setChecked(True)
158
+ self.btn_inapp.setToolTip("Toggle: use in-app terminal or launch external terminal")
159
+ self.btn_inapp.clicked.connect(self._on_toggle_mode)
160
+ toolbar.addWidget(self.btn_inapp)
161
+
162
+ toolbar.addSeparator()
163
+
164
+ btn_reload = QPushButton("Reload")
165
+ btn_reload.clicked.connect(self._load_config)
166
+ toolbar.addWidget(btn_reload)
167
+
168
+ btn_save = QPushButton("Save")
169
+ btn_save.clicked.connect(self._on_save)
170
+ toolbar.addWidget(btn_save)
171
+
172
+ def _load_config(self) -> None:
173
+ try:
174
+ self._config = load_config(self._config_path)
175
+ self.table_model.set_hosts(list(self._config.hosts))
176
+ self.statusBar().showMessage(f"Loaded {len(self._config.hosts)} hosts from {self._config_path or '~/.ssh/config'}")
177
+ self._run_linter()
178
+ except Exception as e:
179
+ QMessageBox.critical(self, "Error", f"Failed to load SSH config:\n{e}")
180
+
181
+ def _run_linter(self) -> None:
182
+ """Run the config linter and populate the warnings dock."""
183
+ findings = lint_config(self._config, self._config_path)
184
+ self._lint_findings = findings
185
+ self._lint_list.clear()
186
+ if not findings:
187
+ self._lint_dock.hide()
188
+ return
189
+ for f in findings:
190
+ item = QListWidgetItem(f"{severity_icon(f.severity)} {f.host_alias}: {f.message}")
191
+ item.setToolTip(f.suggestion or f.message)
192
+ if f.severity == "error":
193
+ item.setForeground(Qt.GlobalColor.red)
194
+ elif f.severity == "warning":
195
+ item.setForeground(Qt.GlobalColor.darkYellow)
196
+ self._lint_list.addItem(item)
197
+ self._lint_dock.show()
198
+ self.statusBar().showMessage(f"{len(findings)} config warning(s) found.", 5000)
199
+
200
+ def _on_lint_item_clicked(self, item: QListWidgetItem) -> None:
201
+ """Jump to the host referenced by a lint finding."""
202
+ row = self._lint_list.row(item)
203
+ if 0 <= row < len(self._lint_findings):
204
+ finding = self._lint_findings[row]
205
+ for i, host in enumerate(self.table_model.hosts):
206
+ if host.alias == finding.host_alias:
207
+ self.table.selectRow(i)
208
+ break
209
+
210
+ def _filter_hosts(self) -> None:
211
+ query = self.search_edit.text().lower().strip()
212
+ if not query:
213
+ self.table_model.set_hosts(list(self._config.hosts))
214
+ else:
215
+ filtered = [
216
+ h for h in self._config.hosts
217
+ if query in h.alias.lower()
218
+ or query in h.hostname.lower()
219
+ or query in h.user.lower()
220
+ ]
221
+ self.table_model.set_hosts(filtered)
222
+
223
+ def _selected_row(self) -> int:
224
+ idx = self.table.currentIndex()
225
+ return idx.row() if idx.isValid() else -1
226
+
227
+ def _on_add_host(self) -> None:
228
+ dlg = HostEditorDialog(parent=self)
229
+ if dlg.exec() == HostEditorDialog.DialogCode.Accepted:
230
+ host = dlg.result_host()
231
+ self._config.hosts.append(host)
232
+ self.table_model.set_hosts(list(self._config.hosts))
233
+ self.statusBar().showMessage(f"Added host '{host.alias}'. Remember to Save.")
234
+
235
+ def _on_edit_host(self) -> None:
236
+ row = self._selected_row()
237
+ if row < 0:
238
+ return
239
+ host = self.table_model.get_host(row)
240
+ if host is None:
241
+ return
242
+ dlg = HostEditorDialog(host=host, parent=self)
243
+ if dlg.exec() == HostEditorDialog.DialogCode.Accepted:
244
+ updated = dlg.result_host()
245
+ # Update in config — find by original alias
246
+ orig_alias = host.alias
247
+ for i, h in enumerate(self._config.hosts):
248
+ if h.alias == orig_alias:
249
+ self._config.hosts[i] = updated
250
+ break
251
+ self.table_model.set_hosts(list(self._config.hosts))
252
+ self.statusBar().showMessage(f"Updated host '{updated.alias}'. Remember to Save.")
253
+
254
+ def _on_delete_host(self) -> None:
255
+ row = self._selected_row()
256
+ if row < 0:
257
+ return
258
+ host = self.table_model.get_host(row)
259
+ if host is None:
260
+ return
261
+ reply = QMessageBox.question(
262
+ self, "Delete Host",
263
+ f"Remove host '{host.alias}' from your SSH config?\n(This takes effect after Save.)",
264
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
265
+ )
266
+ if reply == QMessageBox.StandardButton.Yes:
267
+ orig_alias = host.alias
268
+ self._config.hosts = [h for h in self._config.hosts if h.alias != orig_alias]
269
+ self.table_model.set_hosts(list(self._config.hosts))
270
+ self.statusBar().showMessage(f"Deleted host '{host.alias}'. Remember to Save.")
271
+
272
+ def _on_connect_clicked(self) -> None:
273
+ if self._terminal_widget is not None:
274
+ self._disconnect()
275
+ else:
276
+ self._on_connect()
277
+
278
+ def _disconnect(self) -> None:
279
+ if self._terminal_widget is not None:
280
+ self._terminal_widget.stop()
281
+ self.terminal_layout.removeWidget(self._terminal_widget)
282
+ self._terminal_widget.deleteLater()
283
+ self._terminal_widget = None
284
+ self.terminal_placeholder.show()
285
+ self.btn_connect.setText("Connect")
286
+ self.statusBar().showMessage("Disconnected.")
287
+
288
+ def _on_connect(self) -> None:
289
+ row = self._selected_row()
290
+ if row < 0:
291
+ QMessageBox.information(self, "Connect", "Select a host first.")
292
+ return
293
+ host = self.table_model.get_host(row)
294
+ if host is None:
295
+ return
296
+
297
+ cmd = build_ssh_command(host)
298
+ if not cmd:
299
+ QMessageBox.warning(self, "Cannot Connect", f"Host '{host.alias}' is a wildcard with no HostName — cannot connect directly.")
300
+ return
301
+
302
+ if self.btn_inapp.isChecked():
303
+ self._connect_in_app(host)
304
+ else:
305
+ self._connect_external(host)
306
+
307
+ def _connect_in_app(self, host: HostEntry) -> None:
308
+ # Clear previous terminal
309
+ if self._terminal_widget is not None:
310
+ self._terminal_widget.stop()
311
+ self.terminal_layout.removeWidget(self._terminal_widget)
312
+ self._terminal_widget.deleteLater()
313
+ self._terminal_widget = None
314
+
315
+ self.terminal_placeholder.hide()
316
+ widget = launch_in_app(host)
317
+ if widget is None:
318
+ self.terminal_placeholder.show()
319
+ QMessageBox.warning(self, "Connect Failed", "Could not start SSH session.")
320
+ return
321
+
322
+ self.terminal_layout.addWidget(widget, 1) # stretch=1 so it fills available space
323
+ self._terminal_widget = widget
324
+ widget.show()
325
+ widget.setFocus()
326
+ widget.closed.connect(lambda code: self._on_terminal_closed(code, host.alias))
327
+ self.btn_connect.setText("Disconnect")
328
+ self.statusBar().showMessage(f"Connected to {host.alias}")
329
+
330
+ def _connect_external(self, host: HostEntry) -> None:
331
+ proc = launch_external(host)
332
+ if proc is None:
333
+ QMessageBox.warning(self, "Connect Failed", "No terminal emulator found. Install one (konsole, gnome-terminal, xterm) or use in-app terminal.")
334
+ return
335
+ self.statusBar().showMessage(f"Launched external terminal for {host.alias}")
336
+
337
+ def _on_test_connection(self) -> None:
338
+ row = self._selected_row()
339
+ if row < 0:
340
+ QMessageBox.information(self, "Test Connection", "Select a host first.")
341
+ return
342
+ host = self.table_model.get_host(row)
343
+ if host is None:
344
+ return
345
+ self.statusBar().showMessage(f"Testing {host.alias}…")
346
+ self._test_worker = _ConnectionTestWorker(host, self)
347
+ self._test_worker.finished.connect(self._on_test_finished)
348
+ self._test_worker.start()
349
+
350
+ def _on_test_finished(self, alias: str, result) -> None:
351
+ if result.success:
352
+ self.statusBar().showMessage(f"{alias}: reachable ({result.latency_ms} ms)", 10000)
353
+ else:
354
+ self.statusBar().showMessage(f"{alias}: {result.error}", 10000)
355
+ QMessageBox.warning(self, "Connection Test", f"{alias}: {result.error}")
356
+ self._test_worker = None
357
+
358
+ def _on_terminal_closed(self, code: int, alias: str) -> None:
359
+ self.statusBar().showMessage(f"Session to {alias} ended (exit {code}).")
360
+ if self._terminal_widget is not None:
361
+ self.terminal_layout.removeWidget(self._terminal_widget)
362
+ self._terminal_widget.deleteLater()
363
+ self._terminal_widget = None
364
+ self.terminal_placeholder.show()
365
+ self.btn_connect.setText("Connect")
366
+
367
+ def _on_toggle_mode(self) -> None:
368
+ if self.btn_inapp.isChecked():
369
+ self.btn_inapp.setText("In-App Terminal")
370
+ else:
371
+ self.btn_inapp.setText("External Terminal")
372
+
373
+ def _on_save(self) -> None:
374
+ try:
375
+ path = save_config(self._config, self._config_path)
376
+ self.statusBar().showMessage(f"Saved to {path}")
377
+ QMessageBox.information(self, "Saved", f"SSH config saved to {path}")
378
+ except Exception as e:
379
+ QMessageBox.critical(self, "Save Error", f"Failed to save:\n{e}")
380
+
381
+ def closeEvent(self, event) -> None:
382
+ if self._terminal_widget is not None:
383
+ self._terminal_widget.stop()
384
+ super().closeEvent(event)
gssh/models.py ADDED
@@ -0,0 +1,91 @@
1
+ """Qt table model for displaying SSH host entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt
8
+ from PyQt6.QtGui import QColor
9
+
10
+ from .config import HostEntry
11
+
12
+
13
+ class HostTableModel(QAbstractTableModel):
14
+ """Model backing the host list table view."""
15
+
16
+ COLUMNS = ["Alias", "Host", "User", "Port", "Proxy Jump"]
17
+ COL_KEYS = ["alias", "hostname", "user", "port", "proxy_jump"]
18
+
19
+ def __init__(self) -> None:
20
+ super().__init__()
21
+ self._hosts: list[HostEntry] = []
22
+
23
+ @property
24
+ def hosts(self) -> list[HostEntry]:
25
+ return self._hosts
26
+
27
+ def set_hosts(self, hosts: list[HostEntry]) -> None:
28
+ self.beginResetModel()
29
+ self._hosts = hosts
30
+ self.endResetModel()
31
+
32
+ def add_host(self, host: HostEntry) -> None:
33
+ row = len(self._hosts)
34
+ self.beginInsertRows(QModelIndex(), row, row)
35
+ self._hosts.append(host)
36
+ self.endInsertRows()
37
+
38
+ def remove_host(self, row: int) -> None:
39
+ if 0 <= row < len(self._hosts):
40
+ self.beginRemoveRows(QModelIndex(), row, row)
41
+ self._hosts.pop(row)
42
+ self.endRemoveRows()
43
+
44
+ def update_host(self, row: int, host: HostEntry) -> None:
45
+ if 0 <= row < len(self._hosts):
46
+ self._hosts[row] = host
47
+ idx = self.index(row, 0)
48
+ self.dataChanged.emit(idx, self.index(row, len(self.COLUMNS) - 1))
49
+
50
+ def get_host(self, row: int) -> HostEntry | None:
51
+ if 0 <= row < len(self._hosts):
52
+ return self._hosts[row]
53
+ return None
54
+
55
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
56
+ return len(self._hosts) if not parent.isValid() else 0
57
+
58
+ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
59
+ return len(self.COLUMNS) if not parent.isValid() else 0
60
+
61
+ def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
62
+ if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
63
+ return self.COLUMNS[section]
64
+ return None
65
+
66
+ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
67
+ if not index.isValid() or not (0 <= index.row() < len(self._hosts)):
68
+ return None
69
+
70
+ host = self._hosts[index.row()]
71
+ col = index.column()
72
+
73
+ if role == Qt.ItemDataRole.DisplayRole:
74
+ key = self.COL_KEYS[col]
75
+ return getattr(host, key, "") or "—"
76
+ if role == Qt.ItemDataRole.ToolTipRole:
77
+ lines = [f"Alias: {host.alias}", f"Host: {host.hostname or '—'}"]
78
+ if host.user:
79
+ lines.append(f"User: {host.user}")
80
+ if host.port:
81
+ lines.append(f"Port: {host.port}")
82
+ if host.identity_file:
83
+ lines.append(f"Key: {host.identity_file}")
84
+ if host.proxy_jump:
85
+ lines.append(f"ProxyJump: {host.proxy_jump}")
86
+ return "\n".join(lines)
87
+ if role == Qt.ItemDataRole.ForegroundRole:
88
+ # Dim wildcard hosts slightly
89
+ if "*" in host.alias:
90
+ return QColor(Qt.GlobalColor.gray)
91
+ return None
gssh/py.typed ADDED
File without changes