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.
- graphical_ssh-0.1.1.dist-info/METADATA +151 -0
- graphical_ssh-0.1.1.dist-info/RECORD +18 -0
- graphical_ssh-0.1.1.dist-info/WHEEL +4 -0
- graphical_ssh-0.1.1.dist-info/entry_points.txt +3 -0
- graphical_ssh-0.1.1.dist-info/licenses/LICENSE +21 -0
- gssh/__init__.py +3 -0
- gssh/__main__.py +43 -0
- gssh/config.py +245 -0
- gssh/connect.py +156 -0
- gssh/data/__init__.py +0 -0
- gssh/data/gssh.desktop +10 -0
- gssh/data/gssh.svg +26 -0
- gssh/host_editor.py +174 -0
- gssh/linter.py +402 -0
- gssh/main_window.py +384 -0
- gssh/models.py +91 -0
- gssh/py.typed +0 -0
- gssh/terminal.py +490 -0
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
|