ixlab-sshui 1.0.0__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.
- ixlab_sshui-1.0.0.dist-info/METADATA +65 -0
- ixlab_sshui-1.0.0.dist-info/RECORD +12 -0
- ixlab_sshui-1.0.0.dist-info/WHEEL +5 -0
- ixlab_sshui-1.0.0.dist-info/entry_points.txt +2 -0
- ixlab_sshui-1.0.0.dist-info/licenses/LICENSE +21 -0
- ixlab_sshui-1.0.0.dist-info/top_level.txt +1 -0
- sshui/__init__.py +19 -0
- sshui/about_dialog.py +103 -0
- sshui/main_window.py +1070 -0
- sshui/option_dialog.py +74 -0
- sshui/tag_dialog.py +247 -0
- sshui/text_prompt_dialog.py +46 -0
sshui/main_window.py
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, List, Optional
|
|
5
|
+
import hashlib
|
|
6
|
+
import shlex
|
|
7
|
+
|
|
8
|
+
from PyQt6.QtCore import Qt
|
|
9
|
+
from PyQt6.QtGui import QAction, QFontDatabase, QColor
|
|
10
|
+
from PyQt6.QtWidgets import (
|
|
11
|
+
QApplication,
|
|
12
|
+
QLabel,
|
|
13
|
+
QListWidget,
|
|
14
|
+
QListWidgetItem,
|
|
15
|
+
QMainWindow,
|
|
16
|
+
QMessageBox,
|
|
17
|
+
QPushButton,
|
|
18
|
+
QSplitter,
|
|
19
|
+
QTableWidget,
|
|
20
|
+
QTableWidgetItem,
|
|
21
|
+
QVBoxLayout,
|
|
22
|
+
QWidget,
|
|
23
|
+
QHeaderView,
|
|
24
|
+
QSizePolicy,
|
|
25
|
+
QToolButton,
|
|
26
|
+
QHBoxLayout,
|
|
27
|
+
QStyle,
|
|
28
|
+
QMenu,
|
|
29
|
+
QLineEdit,
|
|
30
|
+
QComboBox,
|
|
31
|
+
QTextEdit,
|
|
32
|
+
QDialog,
|
|
33
|
+
QTabWidget,
|
|
34
|
+
QTreeWidget,
|
|
35
|
+
QTreeWidgetItem,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from sshcore import config as config_module, settings as settings_module
|
|
39
|
+
from sshcore.models import HostBlock
|
|
40
|
+
from .option_dialog import OptionDialog
|
|
41
|
+
from .text_prompt_dialog import TextPromptDialog
|
|
42
|
+
from .tag_dialog import TagDialog
|
|
43
|
+
from .about_dialog import AboutDialog
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MainWindow(QMainWindow):
|
|
47
|
+
"""Simple PyQt window that lists SSH host blocks via the core APIs."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.setWindowTitle("SSH-UI: The sshcli frontend!")
|
|
52
|
+
self.resize(900, 520)
|
|
53
|
+
self._host_list: QListWidget
|
|
54
|
+
self._host_tree: QTreeWidget
|
|
55
|
+
self._options_table: QTableWidget
|
|
56
|
+
self._blocks: List[HostBlock] = []
|
|
57
|
+
self._visible_blocks: List[HostBlock] = []
|
|
58
|
+
self._viewer_windows: List[QDialog] = []
|
|
59
|
+
self._tag_color_cache: dict[str, QColor] = {}
|
|
60
|
+
self._current_list_item_index: int = -1
|
|
61
|
+
self._current_tree_item: QTreeWidgetItem | None = None
|
|
62
|
+
self._global_tag_definitions: dict[str, str] = {}
|
|
63
|
+
|
|
64
|
+
self._setup_menus()
|
|
65
|
+
self._setup_ui()
|
|
66
|
+
self.load_hosts()
|
|
67
|
+
|
|
68
|
+
def _setup_ui(self) -> None:
|
|
69
|
+
central = QWidget(self)
|
|
70
|
+
layout = QVBoxLayout(central)
|
|
71
|
+
|
|
72
|
+
layout.addWidget(self._build_button_panel(), alignment=Qt.AlignmentFlag.AlignLeft)
|
|
73
|
+
layout.addWidget(self._build_splitter())
|
|
74
|
+
layout.addWidget(self._build_details_panel())
|
|
75
|
+
|
|
76
|
+
layout.setStretch(0, 0) # button panel
|
|
77
|
+
layout.setStretch(1, 1) # splitter takes most height
|
|
78
|
+
layout.setStretch(2, 0) # SSH command panel
|
|
79
|
+
|
|
80
|
+
self.setCentralWidget(central)
|
|
81
|
+
|
|
82
|
+
def _build_button_panel(self) -> QWidget:
|
|
83
|
+
container = QWidget()
|
|
84
|
+
button_bar = QHBoxLayout(container)
|
|
85
|
+
button_bar.setContentsMargins(0, 0, 0, 0)
|
|
86
|
+
button_bar.setSpacing(6)
|
|
87
|
+
|
|
88
|
+
button_bar.addWidget(self._make_tool_button("Refresh", QStyle.StandardPixmap.SP_BrowserReload, self.load_hosts))
|
|
89
|
+
button_bar.addWidget(self._make_tool_button("Add", QStyle.StandardPixmap.SP_FileDialogNewFolder, self._add_host))
|
|
90
|
+
button_bar.addWidget(self._make_tool_button("Duplicate", QStyle.StandardPixmap.SP_FileDialogStart, self._duplicate_host))
|
|
91
|
+
button_bar.addWidget(self._make_tool_button("Delete", QStyle.StandardPixmap.SP_TrashIcon, self._delete_host))
|
|
92
|
+
button_bar.addStretch()
|
|
93
|
+
return container
|
|
94
|
+
|
|
95
|
+
def _build_splitter(self) -> QSplitter:
|
|
96
|
+
splitter = QSplitter()
|
|
97
|
+
splitter.setChildrenCollapsible(False)
|
|
98
|
+
splitter.addWidget(self._build_host_panel())
|
|
99
|
+
splitter.addWidget(self._build_options_panel())
|
|
100
|
+
splitter.setStretchFactor(0, 1)
|
|
101
|
+
splitter.setStretchFactor(1, 2)
|
|
102
|
+
return splitter
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_details_panel(self) -> QWidget:
|
|
107
|
+
container = QWidget()
|
|
108
|
+
layout = QHBoxLayout(container)
|
|
109
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
110
|
+
layout.setSpacing(8)
|
|
111
|
+
|
|
112
|
+
# SSH command section
|
|
113
|
+
self._ssh_command_field = QLineEdit()
|
|
114
|
+
self._ssh_command_field.setReadOnly(True)
|
|
115
|
+
self._ssh_command_field.setPlaceholderText("SSH command")
|
|
116
|
+
layout.addWidget(self._ssh_command_field, stretch=1)
|
|
117
|
+
|
|
118
|
+
copy_button = QPushButton("Copy")
|
|
119
|
+
copy_button.clicked.connect(self._copy_ssh_command) # type: ignore[arg-type]
|
|
120
|
+
layout.addWidget(copy_button)
|
|
121
|
+
|
|
122
|
+
return container
|
|
123
|
+
|
|
124
|
+
def _setup_menus(self) -> None:
|
|
125
|
+
menu_bar = self.menuBar()
|
|
126
|
+
|
|
127
|
+
file_menu = menu_bar.addMenu("File")
|
|
128
|
+
refresh_action = QAction("Refresh Hosts", self)
|
|
129
|
+
refresh_action.setShortcut("Ctrl+R")
|
|
130
|
+
refresh_action.triggered.connect(self.load_hosts) # type: ignore[arg-type]
|
|
131
|
+
file_menu.addAction(refresh_action)
|
|
132
|
+
|
|
133
|
+
file_menu.addSeparator()
|
|
134
|
+
quit_action = QAction("Quit", self)
|
|
135
|
+
quit_action.setShortcut("Ctrl+Q")
|
|
136
|
+
quit_action.triggered.connect(self._quit_application) # type: ignore[arg-type]
|
|
137
|
+
file_menu.addAction(quit_action)
|
|
138
|
+
|
|
139
|
+
help_menu = menu_bar.addMenu("Help")
|
|
140
|
+
about_action = QAction("About sshcli UI", self)
|
|
141
|
+
about_action.triggered.connect(self._show_about_dialog) # type: ignore[arg-type]
|
|
142
|
+
help_menu.addAction(about_action)
|
|
143
|
+
|
|
144
|
+
def _show_about_dialog(self) -> None:
|
|
145
|
+
about_dialog = AboutDialog(self)
|
|
146
|
+
about_dialog.exec() # type: ignore[attr-defined]
|
|
147
|
+
|
|
148
|
+
def _quit_application(self) -> None:
|
|
149
|
+
app = QApplication.instance()
|
|
150
|
+
if app is not None:
|
|
151
|
+
app.quit()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _update_details_label(self, block: Optional[HostBlock]) -> None:
|
|
155
|
+
if block is None:
|
|
156
|
+
count = len(self._blocks)
|
|
157
|
+
self._config_info_label.setText(f"Loaded {count} host{'s' if count != 1 else ''}")
|
|
158
|
+
self._update_command_field(None)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Format: filename:line | HostName: value | Loaded X hosts
|
|
162
|
+
hostnames = block.options.get("HostName", "")
|
|
163
|
+
count = len(self._blocks)
|
|
164
|
+
parts = [f"{block.source_file}:{block.lineno}"]
|
|
165
|
+
if hostnames:
|
|
166
|
+
parts.append(f"HostName: {hostnames}")
|
|
167
|
+
parts.append(f"Loaded {count} host{'s' if count != 1 else ''}")
|
|
168
|
+
|
|
169
|
+
self._config_info_label.setText(" | ".join(parts))
|
|
170
|
+
self._update_command_field(block)
|
|
171
|
+
|
|
172
|
+
def _update_command_field(self, block: Optional[HostBlock]) -> None:
|
|
173
|
+
if block is None:
|
|
174
|
+
self._ssh_command_field.clear()
|
|
175
|
+
return
|
|
176
|
+
command = self._build_ssh_command(block)
|
|
177
|
+
self._ssh_command_field.setText(command)
|
|
178
|
+
|
|
179
|
+
def _build_ssh_command(self, block: HostBlock) -> str:
|
|
180
|
+
options = block.options
|
|
181
|
+
target_host = options.get("HostName") or (block.names_for_listing[0] if block.names_for_listing else block.patterns[0])
|
|
182
|
+
user = options.get("User", "")
|
|
183
|
+
tokens: List[str] = ["ssh"]
|
|
184
|
+
|
|
185
|
+
identity = options.get("IdentityFile")
|
|
186
|
+
if identity:
|
|
187
|
+
tokens.extend(["-i", identity])
|
|
188
|
+
|
|
189
|
+
port = options.get("Port")
|
|
190
|
+
if port:
|
|
191
|
+
tokens.extend(["-p", port])
|
|
192
|
+
|
|
193
|
+
proxy_jump = options.get("ProxyJump")
|
|
194
|
+
if proxy_jump:
|
|
195
|
+
tokens.extend(["-J", proxy_jump])
|
|
196
|
+
|
|
197
|
+
special_keys = {"HostName", "User", "Port", "IdentityFile", "ProxyJump"}
|
|
198
|
+
for key, value in options.items():
|
|
199
|
+
if key in special_keys or not value:
|
|
200
|
+
continue
|
|
201
|
+
tokens.extend(["-o", f"{key}={value}"])
|
|
202
|
+
|
|
203
|
+
target = target_host or block.patterns[0]
|
|
204
|
+
if user:
|
|
205
|
+
target = f"{user}@{target}"
|
|
206
|
+
tokens.append(target)
|
|
207
|
+
|
|
208
|
+
return " ".join(shlex.quote(token) for token in tokens)
|
|
209
|
+
|
|
210
|
+
def _copy_ssh_command(self) -> None:
|
|
211
|
+
text = self._ssh_command_field.text()
|
|
212
|
+
if not text:
|
|
213
|
+
QMessageBox.information(self, "No Command", "No SSH command available to copy.")
|
|
214
|
+
return
|
|
215
|
+
QApplication.instance().clipboard().setText(text)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_host_panel(self) -> QWidget:
|
|
219
|
+
panel = QWidget()
|
|
220
|
+
layout = QVBoxLayout(panel)
|
|
221
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
222
|
+
layout.setSpacing(4)
|
|
223
|
+
|
|
224
|
+
filter_row = QHBoxLayout()
|
|
225
|
+
filter_row.setContentsMargins(0, 0, 0, 0)
|
|
226
|
+
filter_row.setSpacing(4)
|
|
227
|
+
|
|
228
|
+
filter_label = QLabel("Filter")
|
|
229
|
+
filter_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
|
230
|
+
filter_row.addWidget(filter_label)
|
|
231
|
+
|
|
232
|
+
self._filter_mode = QComboBox()
|
|
233
|
+
self._filter_mode.addItems(["Hosts", "Options", "Both"])
|
|
234
|
+
self._filter_mode.currentIndexChanged.connect(lambda _state: self._apply_host_filter())
|
|
235
|
+
filter_row.addWidget(self._filter_mode)
|
|
236
|
+
|
|
237
|
+
self._host_filter = QLineEdit()
|
|
238
|
+
self._host_filter.setPlaceholderText("Type to filter...")
|
|
239
|
+
self._host_filter.textChanged.connect(self._apply_host_filter) # type: ignore[arg-type]
|
|
240
|
+
filter_row.addWidget(self._host_filter)
|
|
241
|
+
|
|
242
|
+
layout.addLayout(filter_row)
|
|
243
|
+
|
|
244
|
+
# Tag filter row
|
|
245
|
+
tag_filter_row = QHBoxLayout()
|
|
246
|
+
tag_filter_row.setContentsMargins(0, 0, 0, 0)
|
|
247
|
+
tag_filter_row.setSpacing(4)
|
|
248
|
+
|
|
249
|
+
tag_label = QLabel("Tag")
|
|
250
|
+
tag_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
|
251
|
+
tag_filter_row.addWidget(tag_label)
|
|
252
|
+
|
|
253
|
+
self._tag_filter = QComboBox()
|
|
254
|
+
self._tag_filter.currentIndexChanged.connect(lambda _state: self._apply_host_filter())
|
|
255
|
+
tag_filter_row.addWidget(self._tag_filter, stretch=1)
|
|
256
|
+
|
|
257
|
+
layout.addLayout(tag_filter_row)
|
|
258
|
+
|
|
259
|
+
# Create tabbed widget for flat and tree views
|
|
260
|
+
self._view_tabs = QTabWidget()
|
|
261
|
+
|
|
262
|
+
# Flat list view (existing)
|
|
263
|
+
self._host_list = QListWidget()
|
|
264
|
+
self._host_list.currentRowChanged.connect(self._show_host_details_from_list) # type: ignore[arg-type]
|
|
265
|
+
self._host_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
266
|
+
self._host_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
267
|
+
self._host_list.customContextMenuRequested.connect(self._show_host_context_menu) # type: ignore[arg-type]
|
|
268
|
+
|
|
269
|
+
# Tree view grouped by tags
|
|
270
|
+
self._host_tree = QTreeWidget()
|
|
271
|
+
self._host_tree.setHeaderHidden(True)
|
|
272
|
+
self._host_tree.currentItemChanged.connect(self._show_host_details_from_tree) # type: ignore[arg-type]
|
|
273
|
+
self._host_tree.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
274
|
+
self._host_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
275
|
+
self._host_tree.customContextMenuRequested.connect(self._show_host_context_menu_tree) # type: ignore[arg-type]
|
|
276
|
+
|
|
277
|
+
# Binding tabs to widget
|
|
278
|
+
self._view_tabs.addTab(self._host_list, "Flat View")
|
|
279
|
+
self._view_tabs.addTab(self._host_tree, "Tag View")
|
|
280
|
+
|
|
281
|
+
# Listening on tab change to render content accordingly
|
|
282
|
+
self._view_tabs.currentChanged.connect(self._host_tab_switched)
|
|
283
|
+
|
|
284
|
+
layout.addWidget(self._view_tabs)
|
|
285
|
+
|
|
286
|
+
return panel
|
|
287
|
+
|
|
288
|
+
def _host_tab_switched(self, index: int) -> None:
|
|
289
|
+
if index == 0:
|
|
290
|
+
self._update_host_details_from_list()
|
|
291
|
+
elif index == 1:
|
|
292
|
+
self._update_host_details_from_tree()
|
|
293
|
+
|
|
294
|
+
def _build_options_panel(self) -> QWidget:
|
|
295
|
+
panel = QWidget()
|
|
296
|
+
layout = QVBoxLayout(panel)
|
|
297
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
298
|
+
layout.setSpacing(4)
|
|
299
|
+
|
|
300
|
+
button_row = QHBoxLayout()
|
|
301
|
+
button_row.setSpacing(6)
|
|
302
|
+
button_row.addWidget(self._make_tool_button("Add option", QStyle.StandardPixmap.SP_FileDialogNewFolder, self._add_option))
|
|
303
|
+
button_row.addWidget(self._make_tool_button("Remove option", QStyle.StandardPixmap.SP_DialogCloseButton, self._remove_option))
|
|
304
|
+
button_row.addStretch()
|
|
305
|
+
layout.addLayout(button_row)
|
|
306
|
+
|
|
307
|
+
self._options_table = QTableWidget(0, 2)
|
|
308
|
+
self._options_table.setHorizontalHeaderLabels(["Option", "Value"])
|
|
309
|
+
self._options_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
310
|
+
self._options_table.verticalHeader().setVisible(False)
|
|
311
|
+
self._options_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
312
|
+
self._options_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
313
|
+
self._options_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
|
314
|
+
self._options_table.cellDoubleClicked.connect(self._edit_option) # type: ignore[arg-type]
|
|
315
|
+
self._options_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
316
|
+
self._options_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
317
|
+
self._options_table.customContextMenuRequested.connect(self._show_option_context_menu) # type: ignore[arg-type]
|
|
318
|
+
layout.addWidget(self._options_table)
|
|
319
|
+
|
|
320
|
+
# Info row below options table
|
|
321
|
+
info_row = QHBoxLayout()
|
|
322
|
+
info_row.setSpacing(8)
|
|
323
|
+
|
|
324
|
+
self._config_info_label = QLabel("No host selected")
|
|
325
|
+
info_row.addWidget(self._config_info_label, stretch=1)
|
|
326
|
+
|
|
327
|
+
open_button = QPushButton("Open Config")
|
|
328
|
+
open_button.clicked.connect(self._open_host_file) # type: ignore[arg-type]
|
|
329
|
+
info_row.addWidget(open_button)
|
|
330
|
+
|
|
331
|
+
layout.addLayout(info_row)
|
|
332
|
+
return panel
|
|
333
|
+
|
|
334
|
+
def _make_button(self, label: str, slot: Callable[[], None]) -> QPushButton:
|
|
335
|
+
button = QPushButton(label)
|
|
336
|
+
button.clicked.connect(slot) # type: ignore[arg-type]
|
|
337
|
+
return button
|
|
338
|
+
|
|
339
|
+
def _make_tool_button(self, text: str, icon: QStyle.StandardPixmap, slot: Callable[[], None]) -> QToolButton:
|
|
340
|
+
button = QToolButton()
|
|
341
|
+
button.setIcon(self.style().standardIcon(icon))
|
|
342
|
+
button.setText(text)
|
|
343
|
+
button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
|
344
|
+
button.setAutoRaise(True)
|
|
345
|
+
button.clicked.connect(slot) # type: ignore[arg-type]
|
|
346
|
+
return button
|
|
347
|
+
|
|
348
|
+
def load_hosts(self) -> None:
|
|
349
|
+
"""Fetch host blocks from the shared config logic and display them."""
|
|
350
|
+
try:
|
|
351
|
+
blocks = config_module.load_host_blocks()
|
|
352
|
+
except Exception as exc: # pragma: no cover - UI feedback
|
|
353
|
+
QMessageBox.critical(self, "Error", f"Failed to load hosts:\n{exc}")
|
|
354
|
+
self._config_info_label.setText("Failed to load hosts")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
self._blocks = blocks
|
|
358
|
+
self._collect_tag_definitions()
|
|
359
|
+
self._populate_tag_filter()
|
|
360
|
+
self._populate_host_list()
|
|
361
|
+
self._populate_host_tree()
|
|
362
|
+
if blocks:
|
|
363
|
+
self._host_list.setCurrentRow(0)
|
|
364
|
+
else:
|
|
365
|
+
self._options_table.setRowCount(0)
|
|
366
|
+
self._update_details_label(None)
|
|
367
|
+
|
|
368
|
+
count = len(blocks)
|
|
369
|
+
# Update the info label to show host count when no host is selected
|
|
370
|
+
if not blocks or self._host_list.currentRow() < 0:
|
|
371
|
+
self._config_info_label.setText(f"Loaded {count} host{'s' if count != 1 else ''}")
|
|
372
|
+
|
|
373
|
+
def _collect_tag_definitions(self) -> None:
|
|
374
|
+
self._global_tag_definitions = {}
|
|
375
|
+
self._tag_color_cache.clear()
|
|
376
|
+
all_defs = settings_module.get_tag_definitions()
|
|
377
|
+
|
|
378
|
+
for tag, color in all_defs.items():
|
|
379
|
+
if color:
|
|
380
|
+
self._global_tag_definitions[tag.lower()] = color
|
|
381
|
+
|
|
382
|
+
def _create_host_list_item_widget(self, block: HostBlock) -> QWidget:
|
|
383
|
+
"""Create a custom widget for displaying a host with tags and color."""
|
|
384
|
+
widget = QWidget()
|
|
385
|
+
layout = QHBoxLayout(widget)
|
|
386
|
+
layout.setContentsMargins(4, 2, 4, 2)
|
|
387
|
+
layout.setSpacing(6)
|
|
388
|
+
|
|
389
|
+
host_name = ", ".join(block.names_for_listing or block.patterns)
|
|
390
|
+
name_label = QLabel(host_name)
|
|
391
|
+
layout.addWidget(name_label)
|
|
392
|
+
|
|
393
|
+
layout.addStretch()
|
|
394
|
+
|
|
395
|
+
if block.tags:
|
|
396
|
+
for tag in block.tags:
|
|
397
|
+
tag_label = self._create_tag_badge_widget(tag)
|
|
398
|
+
layout.addWidget(tag_label)
|
|
399
|
+
|
|
400
|
+
return widget
|
|
401
|
+
|
|
402
|
+
def _create_tag_group_widget(self, tag: str, count: int) -> QWidget:
|
|
403
|
+
widget = QWidget()
|
|
404
|
+
layout = QHBoxLayout(widget)
|
|
405
|
+
layout.setContentsMargins(4, 2, 4, 2)
|
|
406
|
+
layout.setSpacing(6)
|
|
407
|
+
|
|
408
|
+
badge = self._create_tag_badge_widget(tag)
|
|
409
|
+
layout.addWidget(badge)
|
|
410
|
+
|
|
411
|
+
count_label = QLabel(f"({count})")
|
|
412
|
+
layout.addWidget(count_label)
|
|
413
|
+
|
|
414
|
+
layout.addStretch()
|
|
415
|
+
return widget
|
|
416
|
+
|
|
417
|
+
def _create_tag_badge_widget(self, tag: str) -> QLabel:
|
|
418
|
+
"""Create a styled tag badge widget."""
|
|
419
|
+
label = QLabel(tag)
|
|
420
|
+
|
|
421
|
+
qcolor = self._get_tag_color(tag)
|
|
422
|
+
bg_color = qcolor.lighter(130).name()
|
|
423
|
+
text_color = "#000000" if self._is_light_color(qcolor) else "#ffffff"
|
|
424
|
+
|
|
425
|
+
label.setStyleSheet(
|
|
426
|
+
f"""
|
|
427
|
+
QLabel {{
|
|
428
|
+
background-color: {bg_color};
|
|
429
|
+
color: {text_color};
|
|
430
|
+
padding: 2px 6px;
|
|
431
|
+
border-radius: 5px;
|
|
432
|
+
font-size: 10px;
|
|
433
|
+
}}
|
|
434
|
+
"""
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return label
|
|
438
|
+
|
|
439
|
+
def _get_tag_color(self, tag: str) -> QColor:
|
|
440
|
+
tag_lower = tag.lower()
|
|
441
|
+
if tag_lower in self._tag_color_cache:
|
|
442
|
+
return self._tag_color_cache[tag_lower]
|
|
443
|
+
if tag_lower in self._global_tag_definitions:
|
|
444
|
+
color_value = self._global_tag_definitions[tag_lower]
|
|
445
|
+
qcolor = self._map_color_name_to_qcolor(color_value)
|
|
446
|
+
self._tag_color_cache[tag_lower] = qcolor
|
|
447
|
+
return qcolor
|
|
448
|
+
palette = [
|
|
449
|
+
QColor(244, 67, 54),
|
|
450
|
+
QColor(33, 150, 243),
|
|
451
|
+
QColor(76, 175, 80),
|
|
452
|
+
QColor(255, 193, 7),
|
|
453
|
+
QColor(156, 39, 176),
|
|
454
|
+
QColor(0, 188, 212),
|
|
455
|
+
QColor(255, 87, 34),
|
|
456
|
+
QColor(121, 85, 72),
|
|
457
|
+
QColor(96, 125, 139),
|
|
458
|
+
]
|
|
459
|
+
stable_hash = int.from_bytes(hashlib.md5(tag_lower.encode("utf-8")).digest()[:4], "big")
|
|
460
|
+
color = palette[stable_hash % len(palette)]
|
|
461
|
+
self._tag_color_cache[tag_lower] = color
|
|
462
|
+
return color
|
|
463
|
+
|
|
464
|
+
def _is_light_color(self, color: QColor) -> bool:
|
|
465
|
+
luminance = (0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()) / 255
|
|
466
|
+
return luminance > 0.7
|
|
467
|
+
|
|
468
|
+
def _map_color_name_to_qcolor(self, color_name: str) -> QColor:
|
|
469
|
+
"""Map color names to QColor values."""
|
|
470
|
+
color_map = {
|
|
471
|
+
"red": QColor(220, 50, 47),
|
|
472
|
+
"green": QColor(133, 153, 0),
|
|
473
|
+
"blue": QColor(38, 139, 210),
|
|
474
|
+
"yellow": QColor(181, 137, 0),
|
|
475
|
+
"orange": QColor(203, 75, 22),
|
|
476
|
+
"purple": QColor(108, 113, 196),
|
|
477
|
+
"cyan": QColor(42, 161, 152),
|
|
478
|
+
"magenta": QColor(211, 54, 130),
|
|
479
|
+
"gray": QColor(147, 161, 161),
|
|
480
|
+
"grey": QColor(147, 161, 161),
|
|
481
|
+
}
|
|
482
|
+
# Try to get from map, otherwise try to parse as hex or return default
|
|
483
|
+
if color_name.lower() in color_map:
|
|
484
|
+
return color_map[color_name.lower()]
|
|
485
|
+
# Try to parse as hex color
|
|
486
|
+
qcolor = QColor(color_name)
|
|
487
|
+
if qcolor.isValid():
|
|
488
|
+
return qcolor
|
|
489
|
+
# Default to gray if color is not recognized
|
|
490
|
+
return QColor(147, 161, 161)
|
|
491
|
+
|
|
492
|
+
def _populate_tag_filter(self) -> None:
|
|
493
|
+
"""Populate the tag filter dropdown with all unique tags and their counts."""
|
|
494
|
+
if not hasattr(self, "_tag_filter"):
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
# Collect all tags and count occurrences
|
|
498
|
+
tag_counts: dict[str, int] = {}
|
|
499
|
+
for block in self._blocks:
|
|
500
|
+
for tag in block.tags:
|
|
501
|
+
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
502
|
+
|
|
503
|
+
# Block signals to prevent triggering filter during population
|
|
504
|
+
self._tag_filter.blockSignals(True)
|
|
505
|
+
self._tag_filter.clear()
|
|
506
|
+
|
|
507
|
+
# Add "All" option as the first item
|
|
508
|
+
self._tag_filter.addItem("All")
|
|
509
|
+
|
|
510
|
+
# Add tags sorted alphabetically with counts
|
|
511
|
+
for tag in sorted(tag_counts.keys()):
|
|
512
|
+
count = tag_counts[tag]
|
|
513
|
+
self._tag_filter.addItem(f"{tag} ({count})")
|
|
514
|
+
|
|
515
|
+
# Restore signals
|
|
516
|
+
self._tag_filter.blockSignals(False)
|
|
517
|
+
|
|
518
|
+
def _get_selected_tag(self) -> Optional[str]:
|
|
519
|
+
"""Get the currently selected tag from the tag filter dropdown.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
The tag name if a specific tag is selected, or None if "All" is selected.
|
|
523
|
+
"""
|
|
524
|
+
if not hasattr(self, "_tag_filter"):
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
selected_text = self._tag_filter.currentText()
|
|
528
|
+
if not selected_text or selected_text == "All":
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
# Extract tag name from "tag (count)" format
|
|
532
|
+
if " (" in selected_text:
|
|
533
|
+
return selected_text.split(" (")[0]
|
|
534
|
+
|
|
535
|
+
return selected_text
|
|
536
|
+
|
|
537
|
+
def _populate_host_list(self) -> None:
|
|
538
|
+
self._host_list.clear()
|
|
539
|
+
query = (self._host_filter.text() if hasattr(self, "_host_filter") else "").lower()
|
|
540
|
+
mode_widget = getattr(self, "_filter_mode", None)
|
|
541
|
+
mode = mode_widget.currentText().lower() if mode_widget else "host"
|
|
542
|
+
|
|
543
|
+
# Apply text filter
|
|
544
|
+
filtered = [block for block in self._blocks if not query or self._matches_filter(block, query, mode)]
|
|
545
|
+
|
|
546
|
+
# Apply tag filter
|
|
547
|
+
if hasattr(self, "_tag_filter"):
|
|
548
|
+
selected_tag = self._get_selected_tag()
|
|
549
|
+
if selected_tag: # If a specific tag is selected (not "All")
|
|
550
|
+
filtered = [block for block in filtered if block.has_tag(selected_tag)]
|
|
551
|
+
|
|
552
|
+
self._visible_blocks = filtered
|
|
553
|
+
for block in filtered:
|
|
554
|
+
# Create custom widget for the list item
|
|
555
|
+
widget = self._create_host_list_item_widget(block)
|
|
556
|
+
|
|
557
|
+
detail = f"{block.source_file}:{block.lineno}"
|
|
558
|
+
item = QListWidgetItem()
|
|
559
|
+
item.setToolTip(detail)
|
|
560
|
+
item.setSizeHint(widget.sizeHint())
|
|
561
|
+
self._host_list.addItem(item)
|
|
562
|
+
self._host_list.setItemWidget(item, widget)
|
|
563
|
+
if not filtered:
|
|
564
|
+
self._options_table.setRowCount(0)
|
|
565
|
+
self._update_details_label(None)
|
|
566
|
+
|
|
567
|
+
def _matches_filter(self, block: HostBlock, query: str, mode: str) -> bool:
|
|
568
|
+
haystacks = [
|
|
569
|
+
" ".join(block.patterns),
|
|
570
|
+
", ".join(block.names_for_listing or block.patterns),
|
|
571
|
+
block.options.get("HostName", ""),
|
|
572
|
+
]
|
|
573
|
+
if mode in ("options", "both"):
|
|
574
|
+
haystacks.extend([key for key in block.options.keys()])
|
|
575
|
+
haystacks.extend(block.options.values())
|
|
576
|
+
if mode == "options":
|
|
577
|
+
haystacks = haystacks[3:] # only option entries
|
|
578
|
+
return any(query in text.lower() for text in haystacks if text)
|
|
579
|
+
|
|
580
|
+
def _populate_host_tree(self) -> None:
|
|
581
|
+
"""Populate the tree view with hosts grouped by tags."""
|
|
582
|
+
self._host_tree.clear()
|
|
583
|
+
|
|
584
|
+
query = (self._host_filter.text() if hasattr(self, "_host_filter") else "").lower()
|
|
585
|
+
mode_widget = getattr(self, "_filter_mode", None)
|
|
586
|
+
mode = mode_widget.currentText().lower() if mode_widget else "host"
|
|
587
|
+
|
|
588
|
+
# Apply text filter
|
|
589
|
+
filtered = [block for block in self._blocks if not query or self._matches_filter(block, query, mode)]
|
|
590
|
+
|
|
591
|
+
# Apply tag filter
|
|
592
|
+
if hasattr(self, "_tag_filter"):
|
|
593
|
+
selected_tag = self._get_selected_tag()
|
|
594
|
+
if selected_tag: # If a specific tag is selected (not "All")
|
|
595
|
+
filtered = [block for block in filtered if block.has_tag(selected_tag)]
|
|
596
|
+
|
|
597
|
+
# Group hosts by tags
|
|
598
|
+
tag_groups: dict[str, List[HostBlock]] = {}
|
|
599
|
+
untagged: List[HostBlock] = []
|
|
600
|
+
|
|
601
|
+
for block in filtered:
|
|
602
|
+
if not block.tags:
|
|
603
|
+
untagged.append(block)
|
|
604
|
+
else:
|
|
605
|
+
for tag in block.tags:
|
|
606
|
+
if tag not in tag_groups:
|
|
607
|
+
tag_groups[tag] = []
|
|
608
|
+
tag_groups[tag].append(block)
|
|
609
|
+
|
|
610
|
+
# Add tagged groups
|
|
611
|
+
for tag in sorted(tag_groups.keys()):
|
|
612
|
+
tag_item = QTreeWidgetItem(self._host_tree)
|
|
613
|
+
tag_item.setData(0, Qt.ItemDataRole.UserRole, None)
|
|
614
|
+
tag_item.setExpanded(True)
|
|
615
|
+
widget = self._create_tag_group_widget(tag, len(tag_groups[tag]))
|
|
616
|
+
self._host_tree.setItemWidget(tag_item, 0, widget)
|
|
617
|
+
tag_item.setSizeHint(0, widget.sizeHint())
|
|
618
|
+
|
|
619
|
+
for block in tag_groups[tag]:
|
|
620
|
+
host_item = QTreeWidgetItem(tag_item)
|
|
621
|
+
host_item.setData(0, Qt.ItemDataRole.UserRole, block)
|
|
622
|
+
host_item.setToolTip(0, f"{block.source_file}:{block.lineno}")
|
|
623
|
+
host_widget = self._create_host_list_item_widget(block)
|
|
624
|
+
self._host_tree.setItemWidget(host_item, 0, host_widget)
|
|
625
|
+
host_item.setSizeHint(0, host_widget.sizeHint())
|
|
626
|
+
|
|
627
|
+
if untagged:
|
|
628
|
+
untagged_item = QTreeWidgetItem(self._host_tree)
|
|
629
|
+
untagged_item.setData(0, Qt.ItemDataRole.UserRole, None)
|
|
630
|
+
untagged_item.setExpanded(True)
|
|
631
|
+
widget = self._create_tag_group_widget("Untagged", len(untagged))
|
|
632
|
+
self._host_tree.setItemWidget(untagged_item, 0, widget)
|
|
633
|
+
untagged_item.setSizeHint(0, widget.sizeHint())
|
|
634
|
+
|
|
635
|
+
for block in untagged:
|
|
636
|
+
host_item = QTreeWidgetItem(untagged_item)
|
|
637
|
+
host_item.setData(0, Qt.ItemDataRole.UserRole, block)
|
|
638
|
+
host_item.setToolTip(0, f"{block.source_file}:{block.lineno}")
|
|
639
|
+
host_widget = self._create_host_list_item_widget(block)
|
|
640
|
+
self._host_tree.setItemWidget(host_item, 0, host_widget)
|
|
641
|
+
host_item.setSizeHint(0, host_widget.sizeHint())
|
|
642
|
+
|
|
643
|
+
def _apply_host_filter(self) -> None:
|
|
644
|
+
selected = self._current_block()
|
|
645
|
+
self._populate_host_list()
|
|
646
|
+
self._populate_host_tree()
|
|
647
|
+
|
|
648
|
+
# Update selection in flat view
|
|
649
|
+
if selected and selected in self._visible_blocks:
|
|
650
|
+
self._host_list.setCurrentRow(self._visible_blocks.index(selected))
|
|
651
|
+
elif self._visible_blocks:
|
|
652
|
+
self._host_list.setCurrentRow(0)
|
|
653
|
+
else:
|
|
654
|
+
self._host_list.setCurrentRow(-1)
|
|
655
|
+
self._update_details_label(None)
|
|
656
|
+
|
|
657
|
+
def _show_host_details(self, block: Optional[HostBlock]) -> None:
|
|
658
|
+
"""Display details for a given host block."""
|
|
659
|
+
if block is None:
|
|
660
|
+
self._options_table.setRowCount(0)
|
|
661
|
+
self._update_details_label(None)
|
|
662
|
+
return
|
|
663
|
+
items = sorted(block.options.items(), key=lambda kv: kv[0].lower())
|
|
664
|
+
self._options_table.setRowCount(len(items))
|
|
665
|
+
for row, (key, value) in enumerate(items):
|
|
666
|
+
key_item = QTableWidgetItem(key)
|
|
667
|
+
key_item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
|
668
|
+
value_item = QTableWidgetItem(value)
|
|
669
|
+
value_item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
|
|
670
|
+
self._options_table.setItem(row, 0, key_item)
|
|
671
|
+
self._options_table.setItem(row, 1, value_item)
|
|
672
|
+
self._update_details_label(block)
|
|
673
|
+
|
|
674
|
+
def _show_host_details_from_list(self, index: int) -> None:
|
|
675
|
+
"""Handle selection change in flat list view."""
|
|
676
|
+
self._current_list_item_index = index
|
|
677
|
+
self._update_host_details_from_list()
|
|
678
|
+
|
|
679
|
+
def _update_host_details_from_list(self):
|
|
680
|
+
|
|
681
|
+
if self._current_list_item_index < 0 or self._current_list_item_index >= len(self._visible_blocks):
|
|
682
|
+
self._show_host_details(None)
|
|
683
|
+
return
|
|
684
|
+
block = self._visible_blocks[self._current_list_item_index]
|
|
685
|
+
self._show_host_details(block)
|
|
686
|
+
|
|
687
|
+
def _show_host_details_from_tree(self, current: QTreeWidgetItem, previous: QTreeWidgetItem) -> None:
|
|
688
|
+
"""Handle selection change in tree view."""
|
|
689
|
+
self._current_tree_item = current
|
|
690
|
+
self._update_host_details_from_tree()
|
|
691
|
+
|
|
692
|
+
def _update_host_details_from_tree(self):
|
|
693
|
+
if self._current_tree_item is None:
|
|
694
|
+
self._show_host_details(None)
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
# Get the block stored in the item's data
|
|
698
|
+
block = self._current_tree_item.data(0, Qt.ItemDataRole.UserRole)
|
|
699
|
+
if isinstance(block, HostBlock):
|
|
700
|
+
self._show_host_details(block)
|
|
701
|
+
else:
|
|
702
|
+
# This is a tag node, not a host
|
|
703
|
+
self._show_host_details(None)
|
|
704
|
+
|
|
705
|
+
def _current_block(self) -> Optional[HostBlock]:
|
|
706
|
+
"""Get the currently selected host block from either view."""
|
|
707
|
+
# Check which tab is active
|
|
708
|
+
if self._view_tabs.currentIndex() == 0:
|
|
709
|
+
# Flat list view
|
|
710
|
+
index = self._host_list.currentRow()
|
|
711
|
+
if index < 0 or index >= len(self._visible_blocks):
|
|
712
|
+
return None
|
|
713
|
+
return self._visible_blocks[index]
|
|
714
|
+
else:
|
|
715
|
+
# Tree view
|
|
716
|
+
current = self._host_tree.currentItem()
|
|
717
|
+
if current is None:
|
|
718
|
+
return None
|
|
719
|
+
block = current.data(0, Qt.ItemDataRole.UserRole)
|
|
720
|
+
if isinstance(block, HostBlock):
|
|
721
|
+
return block
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
def _select_host_by_name(self, pattern: str) -> None:
|
|
725
|
+
"""Select a host by pattern in the currently active view."""
|
|
726
|
+
# Try to find the host in visible blocks
|
|
727
|
+
for idx, block in enumerate(self._visible_blocks):
|
|
728
|
+
if pattern in block.patterns:
|
|
729
|
+
# Select in the appropriate view based on active tab
|
|
730
|
+
if self._view_tabs.currentIndex() == 0:
|
|
731
|
+
# Flat list view
|
|
732
|
+
self._host_list.setCurrentRow(idx)
|
|
733
|
+
else:
|
|
734
|
+
# Tree view - find and select the item
|
|
735
|
+
self._select_host_in_tree(block)
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
# If filtered out, clear filter to show it
|
|
739
|
+
if hasattr(self, "_host_filter") and self._host_filter.text():
|
|
740
|
+
self._host_filter.blockSignals(True)
|
|
741
|
+
self._host_filter.clear()
|
|
742
|
+
self._host_filter.blockSignals(False)
|
|
743
|
+
self._populate_host_list()
|
|
744
|
+
self._populate_host_tree()
|
|
745
|
+
for idx, block in enumerate(self._visible_blocks):
|
|
746
|
+
if pattern in block.patterns:
|
|
747
|
+
if self._view_tabs.currentIndex() == 0:
|
|
748
|
+
self._host_list.setCurrentRow(idx)
|
|
749
|
+
else:
|
|
750
|
+
self._select_host_in_tree(block)
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
def _select_host_in_tree(self, target_block: HostBlock) -> None:
|
|
754
|
+
"""Find and select a host block in the tree view."""
|
|
755
|
+
root = self._host_tree.invisibleRootItem()
|
|
756
|
+
for i in range(root.childCount()):
|
|
757
|
+
tag_node = root.child(i)
|
|
758
|
+
for j in range(tag_node.childCount()):
|
|
759
|
+
host_item = tag_node.child(j)
|
|
760
|
+
block = host_item.data(0, Qt.ItemDataRole.UserRole)
|
|
761
|
+
if block == target_block:
|
|
762
|
+
self._host_tree.setCurrentItem(host_item)
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
def _prompt_text(self, title: str, label: str, *, text: str = "", allow_empty: bool = False) -> Optional[str]:
|
|
766
|
+
dialog = TextPromptDialog(self, title=title, label=label, default=text, allow_empty=allow_empty)
|
|
767
|
+
if dialog.exec() != dialog.DialogCode.Accepted:
|
|
768
|
+
return None
|
|
769
|
+
return dialog.value if dialog.value or allow_empty else None
|
|
770
|
+
|
|
771
|
+
def _add_host(self) -> None:
|
|
772
|
+
pattern = self._prompt_text("Add Host", "Host pattern:")
|
|
773
|
+
if not pattern:
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
hostname = self._prompt_text("Add Host", "HostName (optional):", allow_empty=True)
|
|
777
|
+
if hostname is None:
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
options = []
|
|
781
|
+
if hostname:
|
|
782
|
+
options.append(("HostName", hostname))
|
|
783
|
+
|
|
784
|
+
target = config_module.default_config_path()
|
|
785
|
+
try:
|
|
786
|
+
config_module.append_host_block(target, [pattern], options)
|
|
787
|
+
except Exception as exc:
|
|
788
|
+
QMessageBox.critical(self, "Error", f"Failed to add host:\n{exc}")
|
|
789
|
+
return
|
|
790
|
+
|
|
791
|
+
self.load_hosts()
|
|
792
|
+
self._select_host_by_name(pattern)
|
|
793
|
+
|
|
794
|
+
def _duplicate_host(self) -> None:
|
|
795
|
+
block = self._current_block()
|
|
796
|
+
if block is None:
|
|
797
|
+
QMessageBox.warning(self, "No Host Selected", "Select a host to duplicate.")
|
|
798
|
+
return
|
|
799
|
+
|
|
800
|
+
new_pattern = self._prompt_text(
|
|
801
|
+
"Duplicate Host",
|
|
802
|
+
"New host pattern:",
|
|
803
|
+
text=f"{block.patterns[0]}-copy",
|
|
804
|
+
)
|
|
805
|
+
if not new_pattern:
|
|
806
|
+
return
|
|
807
|
+
|
|
808
|
+
options = list(block.options.items())
|
|
809
|
+
target = config_module.default_config_path()
|
|
810
|
+
try:
|
|
811
|
+
config_module.append_host_block(target, [new_pattern], options, tags=block.tags)
|
|
812
|
+
except Exception as exc:
|
|
813
|
+
QMessageBox.critical(self, "Error", f"Failed to duplicate host:\n{exc}")
|
|
814
|
+
return
|
|
815
|
+
|
|
816
|
+
self.load_hosts()
|
|
817
|
+
self._select_host_by_name(new_pattern)
|
|
818
|
+
|
|
819
|
+
def _delete_host(self) -> None:
|
|
820
|
+
block = self._current_block()
|
|
821
|
+
if block is None:
|
|
822
|
+
QMessageBox.warning(self, "No Host Selected", "Select a host to delete.")
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
response = QMessageBox.question(
|
|
826
|
+
self,
|
|
827
|
+
"Delete Host",
|
|
828
|
+
f"Are you sure you want to delete host '{' '.join(block.patterns)}'?",
|
|
829
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
830
|
+
)
|
|
831
|
+
if response != QMessageBox.StandardButton.Yes:
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
try:
|
|
835
|
+
config_module.remove_host_block(Path(block.source_file), block)
|
|
836
|
+
except Exception as exc:
|
|
837
|
+
QMessageBox.critical(self, "Error", f"Failed to delete host:\n{exc}")
|
|
838
|
+
return
|
|
839
|
+
|
|
840
|
+
self.load_hosts()
|
|
841
|
+
|
|
842
|
+
def _add_option(self) -> None:
|
|
843
|
+
block = self._current_block()
|
|
844
|
+
if block is None:
|
|
845
|
+
QMessageBox.warning(self, "No Host Selected", "Select a host before adding options.")
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
dialog = OptionDialog(self, title="Add Option")
|
|
849
|
+
if dialog.exec() != dialog.DialogCode.Accepted:
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
key = dialog.option_name.strip()
|
|
853
|
+
value = dialog.option_value.strip()
|
|
854
|
+
|
|
855
|
+
options = list(block.options.items())
|
|
856
|
+
for idx, (existing_key, _) in enumerate(options):
|
|
857
|
+
if existing_key.lower() == key.lower():
|
|
858
|
+
options[idx] = (existing_key, value)
|
|
859
|
+
break
|
|
860
|
+
else:
|
|
861
|
+
options.append((key, value))
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
config_module.replace_host_block_with_metadata(Path(block.source_file), block, list(block.patterns), options)
|
|
865
|
+
except Exception as exc:
|
|
866
|
+
QMessageBox.critical(self, "Error", f"Failed to update host:\n{exc}")
|
|
867
|
+
return
|
|
868
|
+
|
|
869
|
+
self.load_hosts()
|
|
870
|
+
self._select_host_by_name(block.patterns[0])
|
|
871
|
+
|
|
872
|
+
def _edit_option(self, row: int, column: int) -> None:
|
|
873
|
+
block = self._current_block()
|
|
874
|
+
if block is None:
|
|
875
|
+
return
|
|
876
|
+
if row < 0 or row >= len(block.options):
|
|
877
|
+
return
|
|
878
|
+
|
|
879
|
+
items = sorted(block.options.items(), key=lambda kv: kv[0].lower())
|
|
880
|
+
key, value = items[row]
|
|
881
|
+
|
|
882
|
+
dialog = OptionDialog(self, title=f"Edit Option – {key}", initial_option=key, initial_value=value)
|
|
883
|
+
if dialog.exec() != dialog.DialogCode.Accepted:
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
new_key = dialog.option_name.strip()
|
|
887
|
+
new_value = dialog.option_value.strip()
|
|
888
|
+
|
|
889
|
+
options = list(block.options.items())
|
|
890
|
+
updated = False
|
|
891
|
+
for idx, (existing_key, _) in enumerate(options):
|
|
892
|
+
if existing_key.lower() == key.lower():
|
|
893
|
+
options[idx] = (new_key or key, new_value)
|
|
894
|
+
updated = True
|
|
895
|
+
break
|
|
896
|
+
if not updated:
|
|
897
|
+
options.append((new_key, new_value))
|
|
898
|
+
|
|
899
|
+
try:
|
|
900
|
+
config_module.replace_host_block_with_metadata(Path(block.source_file), block, list(block.patterns), options)
|
|
901
|
+
except Exception as exc:
|
|
902
|
+
QMessageBox.critical(self, "Error", f"Failed to update option:\n{exc}")
|
|
903
|
+
return
|
|
904
|
+
|
|
905
|
+
self.load_hosts()
|
|
906
|
+
self._select_host_by_name(block.patterns[0])
|
|
907
|
+
|
|
908
|
+
def _show_option_context_menu(self, pos) -> None:
|
|
909
|
+
item = self._options_table.itemAt(pos)
|
|
910
|
+
if item is None:
|
|
911
|
+
return
|
|
912
|
+
column = item.column()
|
|
913
|
+
if column != 1:
|
|
914
|
+
return
|
|
915
|
+
menu = QMenu(self)
|
|
916
|
+
menu.addAction("Copy value", lambda: self._copy_option_value(item.text()))
|
|
917
|
+
menu.exec(self._options_table.viewport().mapToGlobal(pos))
|
|
918
|
+
|
|
919
|
+
def _copy_option_value(self, value: str) -> None:
|
|
920
|
+
clipboard = QApplication.instance().clipboard()
|
|
921
|
+
clipboard.setText(value)
|
|
922
|
+
|
|
923
|
+
def _remove_option(self) -> None:
|
|
924
|
+
block = self._current_block()
|
|
925
|
+
if block is None:
|
|
926
|
+
QMessageBox.warning(self, "No Host Selected", "Select a host before removing options.")
|
|
927
|
+
return
|
|
928
|
+
row = self._options_table.currentRow()
|
|
929
|
+
if row < 0 or row >= self._options_table.rowCount():
|
|
930
|
+
QMessageBox.warning(self, "No Option Selected", "Select an option row to remove.")
|
|
931
|
+
return
|
|
932
|
+
|
|
933
|
+
option_key = self._options_table.item(row, 0).text()
|
|
934
|
+
response = QMessageBox.question(
|
|
935
|
+
self,
|
|
936
|
+
"Remove Option",
|
|
937
|
+
f"Are you sure you want to remove option '{option_key}'?",
|
|
938
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
939
|
+
)
|
|
940
|
+
if response != QMessageBox.StandardButton.Yes:
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
options = [(k, v) for k, v in block.options.items() if k != option_key]
|
|
944
|
+
try:
|
|
945
|
+
config_module.replace_host_block_with_metadata(Path(block.source_file), block, list(block.patterns), options)
|
|
946
|
+
except Exception as exc:
|
|
947
|
+
QMessageBox.critical(self, "Error", f"Failed to remove option:\n{exc}")
|
|
948
|
+
return
|
|
949
|
+
|
|
950
|
+
self.load_hosts()
|
|
951
|
+
self._select_host_by_name(block.patterns[0])
|
|
952
|
+
|
|
953
|
+
def _open_host_file(self) -> None:
|
|
954
|
+
block = self._current_block()
|
|
955
|
+
if block is None:
|
|
956
|
+
QMessageBox.information(self, "No Host Selected", "Select a host to open its config.")
|
|
957
|
+
return
|
|
958
|
+
path = Path(block.source_file)
|
|
959
|
+
if not path.exists():
|
|
960
|
+
QMessageBox.warning(self, "File Missing", f"{path} does not exist.")
|
|
961
|
+
return
|
|
962
|
+
try:
|
|
963
|
+
text = path.read_text(encoding="utf-8")
|
|
964
|
+
except Exception as exc:
|
|
965
|
+
QMessageBox.warning(self, "Cannot Read", f"Failed to read {path}:\n{exc}")
|
|
966
|
+
return
|
|
967
|
+
|
|
968
|
+
dialog = QDialog(self)
|
|
969
|
+
dialog.setWindowTitle(str(path))
|
|
970
|
+
layout = QVBoxLayout(dialog)
|
|
971
|
+
|
|
972
|
+
info_label = QLabel(f"Viewing: {path}")
|
|
973
|
+
layout.addWidget(info_label)
|
|
974
|
+
|
|
975
|
+
viewer = QTextEdit()
|
|
976
|
+
viewer.setReadOnly(True)
|
|
977
|
+
viewer.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
|
978
|
+
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
|
979
|
+
viewer.setFont(font)
|
|
980
|
+
viewer.setText(text)
|
|
981
|
+
layout.addWidget(viewer)
|
|
982
|
+
|
|
983
|
+
dialog.resize(900, 600)
|
|
984
|
+
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
985
|
+
self._register_viewer(dialog)
|
|
986
|
+
dialog.show()
|
|
987
|
+
|
|
988
|
+
def _show_host_context_menu(self, pos) -> None:
|
|
989
|
+
"""Show context menu for host list."""
|
|
990
|
+
item = self._host_list.itemAt(pos)
|
|
991
|
+
if item is None:
|
|
992
|
+
return
|
|
993
|
+
|
|
994
|
+
menu = QMenu(self)
|
|
995
|
+
menu.addAction("Edit Tags...", self._edit_tags)
|
|
996
|
+
menu.addAction("Delete", self._delete_host)
|
|
997
|
+
menu.exec(self._host_list.viewport().mapToGlobal(pos))
|
|
998
|
+
|
|
999
|
+
def _show_host_context_menu_tree(self, pos) -> None:
|
|
1000
|
+
"""Show context menu for host tree."""
|
|
1001
|
+
item = self._host_tree.itemAt(pos)
|
|
1002
|
+
if item is None:
|
|
1003
|
+
return
|
|
1004
|
+
|
|
1005
|
+
# Only show menu if it's a host item (not a tag group)
|
|
1006
|
+
block = item.data(0, Qt.ItemDataRole.UserRole)
|
|
1007
|
+
if not isinstance(block, HostBlock):
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
menu = QMenu(self)
|
|
1011
|
+
menu.addAction("Edit Tags...", self._edit_tags)
|
|
1012
|
+
menu.addAction("Delete", self._delete_host)
|
|
1013
|
+
menu.exec(self._host_tree.viewport().mapToGlobal(pos))
|
|
1014
|
+
|
|
1015
|
+
def _edit_tags(self) -> None:
|
|
1016
|
+
"""Open the tag edit dialog for the selected host."""
|
|
1017
|
+
block = self._current_block()
|
|
1018
|
+
if block is None:
|
|
1019
|
+
QMessageBox.warning(self, "No Host Selected", "Select a host to edit tags.")
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
# Collect all existing tags from all blocks for autocomplete
|
|
1023
|
+
all_tags: List[str] = []
|
|
1024
|
+
for b in self._blocks:
|
|
1025
|
+
for tag in b.tags:
|
|
1026
|
+
if tag not in all_tags:
|
|
1027
|
+
all_tags.append(tag)
|
|
1028
|
+
all_tags.sort()
|
|
1029
|
+
|
|
1030
|
+
tag_defs = settings_module.get_tag_definitions()
|
|
1031
|
+
|
|
1032
|
+
dialog = TagDialog(
|
|
1033
|
+
self,
|
|
1034
|
+
title=f"Edit Tags: {', '.join(block.patterns)}",
|
|
1035
|
+
current_tags=block.tags,
|
|
1036
|
+
all_tags=all_tags,
|
|
1037
|
+
tag_definitions=tag_defs,
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
if dialog.exec() != dialog.DialogCode.Accepted:
|
|
1041
|
+
return
|
|
1042
|
+
|
|
1043
|
+
block.tags = dialog.tags
|
|
1044
|
+
|
|
1045
|
+
try:
|
|
1046
|
+
# Update the host block first so we rely on the original line numbers.
|
|
1047
|
+
config_module.replace_host_block_with_metadata(
|
|
1048
|
+
Path(block.source_file),
|
|
1049
|
+
block,
|
|
1050
|
+
list(block.patterns),
|
|
1051
|
+
list(block.options.items())
|
|
1052
|
+
)
|
|
1053
|
+
# Now persist any tag definition changes.
|
|
1054
|
+
settings_module.update_tag_definitions(dialog.tag_definitions)
|
|
1055
|
+
except Exception as exc:
|
|
1056
|
+
QMessageBox.critical(self, "Error", f"Failed to save tags:\n{exc}")
|
|
1057
|
+
return
|
|
1058
|
+
|
|
1059
|
+
# Reload hosts to reflect changes
|
|
1060
|
+
self.load_hosts()
|
|
1061
|
+
self._select_host_by_name(block.patterns[0])
|
|
1062
|
+
|
|
1063
|
+
def _register_viewer(self, dialog: QDialog) -> None:
|
|
1064
|
+
self._viewer_windows.append(dialog)
|
|
1065
|
+
|
|
1066
|
+
def _cleanup(*_args) -> None:
|
|
1067
|
+
if dialog in self._viewer_windows:
|
|
1068
|
+
self._viewer_windows.remove(dialog)
|
|
1069
|
+
|
|
1070
|
+
dialog.destroyed.connect(_cleanup)
|