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.
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)