ntermqt 0.1.3__tar.gz → 0.1.5__tar.gz

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.
Files changed (73) hide show
  1. {ntermqt-0.1.3 → ntermqt-0.1.5}/PKG-INFO +43 -15
  2. {ntermqt-0.1.3 → ntermqt-0.1.5}/README.md +39 -10
  3. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/__main__.py +48 -0
  4. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/manager/tree.py +125 -42
  5. ntermqt-0.1.5/nterm/parser/api_help_dialog.py +607 -0
  6. ntermqt-0.1.5/nterm/parser/ntc_download_dialog.py +372 -0
  7. ntermqt-0.1.5/nterm/parser/tfsm_engine.py +246 -0
  8. ntermqt-0.1.5/nterm/parser/tfsm_fire.py +237 -0
  9. ntermqt-0.1.5/nterm/parser/tfsm_fire_tester.py +2329 -0
  10. ntermqt-0.1.5/nterm/scripting/api.py +1354 -0
  11. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/bridge.py +10 -0
  12. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/resources/terminal.html +9 -4
  13. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/resources/terminal.js +14 -1
  14. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/widget.py +73 -2
  15. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/engine.py +45 -0
  16. ntermqt-0.1.5/nterm/theme/themes/clean.yaml +0 -0
  17. ntermqt-0.1.5/nterm/theme/themes/nord_hybrid.yaml +43 -0
  18. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/vault/store.py +3 -3
  19. {ntermqt-0.1.3 → ntermqt-0.1.5}/ntermqt.egg-info/PKG-INFO +43 -15
  20. {ntermqt-0.1.3 → ntermqt-0.1.5}/ntermqt.egg-info/SOURCES.txt +7 -0
  21. {ntermqt-0.1.3 → ntermqt-0.1.5}/ntermqt.egg-info/requires.txt +3 -5
  22. {ntermqt-0.1.3 → ntermqt-0.1.5}/pyproject.toml +6 -4
  23. ntermqt-0.1.3/nterm/scripting/api.py +0 -447
  24. {ntermqt-0.1.3 → ntermqt-0.1.5}/MANIFEST.in +0 -0
  25. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/__init__.py +0 -0
  26. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/askpass/__init__.py +0 -0
  27. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/askpass/server.py +0 -0
  28. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/config.py +0 -0
  29. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/connection/__init__.py +0 -0
  30. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/connection/profile.py +0 -0
  31. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/manager/__init__.py +0 -0
  32. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/manager/connect_dialog.py +0 -0
  33. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/manager/editor.py +0 -0
  34. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/manager/io.py +0 -0
  35. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/manager/models.py +0 -0
  36. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/manager/settings.py +0 -0
  37. /ntermqt-0.1.3/nterm/theme/themes/clean.yaml → /ntermqt-0.1.5/nterm/parser/__init__.py +0 -0
  38. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/resources.py +0 -0
  39. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/scripting/__init__.py +0 -0
  40. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/scripting/cli.py +0 -0
  41. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/session/__init__.py +0 -0
  42. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/session/askpass_ssh.py +0 -0
  43. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/session/base.py +0 -0
  44. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/session/interactive_ssh.py +0 -0
  45. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/session/local_terminal.py +0 -0
  46. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/session/pty_transport.py +0 -0
  47. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/session/ssh.py +0 -0
  48. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/__init__.py +0 -0
  49. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
  50. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
  51. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
  52. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/resources/xterm.css +0 -0
  53. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/terminal/resources/xterm.min.js +0 -0
  54. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/__init__.py +0 -0
  55. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/stylesheet.py +0 -0
  56. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/default.yaml +0 -0
  57. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/dracula.yaml +0 -0
  58. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/enterprise_dark.yaml +0 -0
  59. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
  60. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/enterprise_light.yaml +0 -0
  61. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
  62. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
  63. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/theme/themes/gruvbox_light.yaml +0 -0
  64. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/vault/__init__.py +0 -0
  65. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/vault/credential_manager.py +0 -0
  66. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/vault/keychain.py +0 -0
  67. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/vault/manager_ui.py +0 -0
  68. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/vault/profile.py +0 -0
  69. {ntermqt-0.1.3 → ntermqt-0.1.5}/nterm/vault/resolver.py +0 -0
  70. {ntermqt-0.1.3 → ntermqt-0.1.5}/ntermqt.egg-info/dependency_links.txt +0 -0
  71. {ntermqt-0.1.3 → ntermqt-0.1.5}/ntermqt.egg-info/entry_points.txt +0 -0
  72. {ntermqt-0.1.3 → ntermqt-0.1.5}/ntermqt.egg-info/top_level.txt +0 -0
  73. {ntermqt-0.1.3 → ntermqt-0.1.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ntermqt
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
5
5
  Author: Scott Peterman
6
6
  License: GPL-3.0
@@ -26,22 +26,21 @@ Requires-Dist: paramiko>=3.0.0
26
26
  Requires-Dist: cryptography>=41.0.0
27
27
  Requires-Dist: pyyaml>=6.0
28
28
  Requires-Dist: click>=8.0.0
29
+ Requires-Dist: ipython>=8.0.0
30
+ Requires-Dist: requests>=2.10.0
31
+ Requires-Dist: textfsm>=2.0.0
29
32
  Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
30
33
  Requires-Dist: pywinpty>=2.0.0; sys_platform == "win32"
31
34
  Provides-Extra: keyring
32
35
  Requires-Dist: keyring>=24.0.0; extra == "keyring"
33
- Provides-Extra: scripting
34
- Requires-Dist: ipython>=8.0.0; extra == "scripting"
35
36
  Provides-Extra: dev
36
37
  Requires-Dist: pytest; extra == "dev"
37
38
  Requires-Dist: black; extra == "dev"
38
39
  Requires-Dist: pyinstaller; extra == "dev"
39
40
  Requires-Dist: build; extra == "dev"
40
41
  Requires-Dist: twine; extra == "dev"
41
- Requires-Dist: ipython>=8.0.0; extra == "dev"
42
42
  Provides-Extra: all
43
43
  Requires-Dist: keyring>=24.0.0; extra == "all"
44
- Requires-Dist: ipython>=8.0.0; extra == "all"
45
44
 
46
45
  # nterm
47
46
 
@@ -59,9 +58,11 @@ Built for managing hundreds of devices through bastion hosts with hardware secur
59
58
 
60
59
  **Terminal**
61
60
  - xterm.js rendering via QWebEngineView — full VT100/ANSI support
62
- - Built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox (dark/light/hybrid)
61
+ - 12 built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox, Enterprise variants
62
+ - Hybrid themes: dark UI chrome with light terminal for readability
63
63
  - Custom YAML themes with independent terminal and UI colors
64
64
  - Tab or window per session — pop sessions to separate windows
65
+ - Session capture to file (clean text, ANSI stripped)
65
66
  - Unicode, emoji, box-drawing characters
66
67
 
67
68
  **Authentication**
@@ -306,19 +307,33 @@ session.connect()
306
307
 
307
308
  ## Themes
308
309
 
309
- ### Built-in
310
+ nterm includes 12 built-in themes covering dark, light, and hybrid styles.
311
+
312
+ ### Built-in Themes
310
313
 
311
314
  ```python
312
- Theme.default() # Catppuccin Mocha
313
- Theme.dracula() # Dracula
314
- Theme.nord() # Nord
315
- Theme.solarized_dark() # Solarized Dark
316
- Theme.gruvbox_dark() # Gruvbox Dark
317
- Theme.gruvbox_light() # Gruvbox Light
318
- Theme.gruvbox_hybrid() # Dark UI + Light terminal
315
+ # Dark themes
316
+ Theme.default() # Catppuccin Mocha
317
+ Theme.dracula() # Dracula
318
+ Theme.nord() # Nord
319
+ Theme.solarized_dark() # Solarized Dark
320
+ Theme.gruvbox_dark() # Gruvbox Dark
321
+ Theme.enterprise_dark() # Microsoft-inspired dark
322
+
323
+ # Light themes
324
+ Theme.gruvbox_light() # Gruvbox Light
325
+ Theme.enterprise_light() # Microsoft-inspired light
326
+ Theme.clean() # Warm paper tones
327
+
328
+ # Hybrid themes (dark UI + light terminal)
329
+ Theme.gruvbox_hybrid() # Gruvbox dark chrome, light terminal
330
+ Theme.nord_hybrid() # Nord polar night chrome, snow storm terminal
331
+ Theme.enterprise_hybrid() # VS Code-style dark/light split
319
332
  ```
320
333
 
321
- ### Custom YAML
334
+ **Hybrid themes** combine a dark application chrome (menus, tabs, sidebars) with a light terminal for maximum readability — ideal for long sessions reviewing configs or logs.
335
+
336
+ ### Custom YAML Themes
322
337
 
323
338
  ```yaml
324
339
  # ~/.nterm/themes/my-theme.yaml
@@ -347,6 +362,19 @@ accent_color: "#7aa2f7"
347
362
 
348
363
  ---
349
364
 
365
+ ## Session Capture
366
+
367
+ Capture session output to a file for documentation, auditing, or extracting config snippets.
368
+
369
+ **Right-click in terminal → Start Capture...** to begin recording. Output is saved as clean text with ANSI escape sequences stripped — ready for grep, diff, or pasting into tickets.
370
+
371
+ - Per-session capture (each tab independent)
372
+ - File dialog for save location
373
+ - Menu shows active capture filename
374
+ - Auto-stops when session closes
375
+
376
+ ---
377
+
350
378
  ## Jump Hosts
351
379
 
352
380
  ```python
@@ -14,9 +14,11 @@ Built for managing hundreds of devices through bastion hosts with hardware secur
14
14
 
15
15
  **Terminal**
16
16
  - xterm.js rendering via QWebEngineView — full VT100/ANSI support
17
- - Built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox (dark/light/hybrid)
17
+ - 12 built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox, Enterprise variants
18
+ - Hybrid themes: dark UI chrome with light terminal for readability
18
19
  - Custom YAML themes with independent terminal and UI colors
19
20
  - Tab or window per session — pop sessions to separate windows
21
+ - Session capture to file (clean text, ANSI stripped)
20
22
  - Unicode, emoji, box-drawing characters
21
23
 
22
24
  **Authentication**
@@ -261,19 +263,33 @@ session.connect()
261
263
 
262
264
  ## Themes
263
265
 
264
- ### Built-in
266
+ nterm includes 12 built-in themes covering dark, light, and hybrid styles.
267
+
268
+ ### Built-in Themes
265
269
 
266
270
  ```python
267
- Theme.default() # Catppuccin Mocha
268
- Theme.dracula() # Dracula
269
- Theme.nord() # Nord
270
- Theme.solarized_dark() # Solarized Dark
271
- Theme.gruvbox_dark() # Gruvbox Dark
272
- Theme.gruvbox_light() # Gruvbox Light
273
- Theme.gruvbox_hybrid() # Dark UI + Light terminal
271
+ # Dark themes
272
+ Theme.default() # Catppuccin Mocha
273
+ Theme.dracula() # Dracula
274
+ Theme.nord() # Nord
275
+ Theme.solarized_dark() # Solarized Dark
276
+ Theme.gruvbox_dark() # Gruvbox Dark
277
+ Theme.enterprise_dark() # Microsoft-inspired dark
278
+
279
+ # Light themes
280
+ Theme.gruvbox_light() # Gruvbox Light
281
+ Theme.enterprise_light() # Microsoft-inspired light
282
+ Theme.clean() # Warm paper tones
283
+
284
+ # Hybrid themes (dark UI + light terminal)
285
+ Theme.gruvbox_hybrid() # Gruvbox dark chrome, light terminal
286
+ Theme.nord_hybrid() # Nord polar night chrome, snow storm terminal
287
+ Theme.enterprise_hybrid() # VS Code-style dark/light split
274
288
  ```
275
289
 
276
- ### Custom YAML
290
+ **Hybrid themes** combine a dark application chrome (menus, tabs, sidebars) with a light terminal for maximum readability — ideal for long sessions reviewing configs or logs.
291
+
292
+ ### Custom YAML Themes
277
293
 
278
294
  ```yaml
279
295
  # ~/.nterm/themes/my-theme.yaml
@@ -302,6 +318,19 @@ accent_color: "#7aa2f7"
302
318
 
303
319
  ---
304
320
 
321
+ ## Session Capture
322
+
323
+ Capture session output to a file for documentation, auditing, or extracting config snippets.
324
+
325
+ **Right-click in terminal → Start Capture...** to begin recording. Output is saved as clean text with ANSI escape sequences stripped — ready for grep, diff, or pasting into tickets.
326
+
327
+ - Per-session capture (each tab independent)
328
+ - File dialog for save location
329
+ - Menu shows active capture filename
330
+ - Auto-stops when session closes
331
+
332
+ ---
333
+
305
334
  ## Jump Hosts
306
335
 
307
336
  ```python
@@ -20,6 +20,8 @@ from nterm.manager import (
20
20
  SessionTreeWidget, SessionStore, SavedSession, QuickConnectDialog,
21
21
  SettingsDialog, ExportDialog, ImportDialog, ImportTerminalTelemetryDialog
22
22
  )
23
+ from nterm.parser.ntc_download_dialog import NTCDownloadDialog
24
+ from nterm.parser.api_help_dialog import APIHelpDialog
23
25
  from nterm.terminal.widget import TerminalWidget
24
26
  from nterm.session.ssh import SSHSession
25
27
  from nterm.session.local_terminal import LocalTerminal
@@ -457,6 +459,25 @@ class MainWindow(QMainWindow):
457
459
  shell_window_action.triggered.connect(lambda: self._open_local("Shell", LocalTerminal(), "window"))
458
460
  shell_menu.addAction(shell_window_action)
459
461
 
462
+ # Separator before tools
463
+ dev_menu.addSeparator()
464
+
465
+ # Download NTC Templates
466
+ download_templates_action = QAction("Download &NTC Templates...", self)
467
+ download_templates_action.triggered.connect(self._on_download_ntc_templates)
468
+ dev_menu.addAction(download_templates_action)
469
+
470
+ # TextFSM Template Tester
471
+ template_tester_action = QAction("TextFSM &Template Tester...", self)
472
+ template_tester_action.triggered.connect(self._on_textfsm_tester)
473
+ dev_menu.addAction(template_tester_action)
474
+
475
+ # API Help
476
+ api_help_action = QAction("&API Help...", self)
477
+ api_help_action.setShortcut(QKeySequence("F1"))
478
+ api_help_action.triggered.connect(self._on_api_help)
479
+ dev_menu.addAction(api_help_action)
480
+
460
481
  def _on_import_sessions(self):
461
482
  """Show import dialog."""
462
483
  dialog = ImportDialog(self.session_store, self)
@@ -474,6 +495,33 @@ class MainWindow(QMainWindow):
474
495
  if dialog.exec():
475
496
  self.session_tree.refresh()
476
497
 
498
+ def _on_download_ntc_templates(self):
499
+ """Show NTC template download dialog."""
500
+ # Use the same db path as the TextFSM engine would use
501
+ db_path = Path.cwd() / "tfsm_templates.db"
502
+ dialog = NTCDownloadDialog(self, str(db_path))
503
+ dialog.exec()
504
+
505
+ def _on_textfsm_tester(self):
506
+ """Launch TextFSM Template Tester."""
507
+ import subprocess
508
+ import sys
509
+
510
+ # Launch as separate process
511
+ try:
512
+ subprocess.Popen([sys.executable, "-m", "nterm.parser.tfsm_fire_tester"])
513
+ except Exception as e:
514
+ QMessageBox.critical(
515
+ self,
516
+ "Launch Error",
517
+ f"Failed to launch TextFSM Template Tester:\n{e}"
518
+ )
519
+
520
+ def _on_api_help(self):
521
+ """Show API help dialog."""
522
+ dialog = APIHelpDialog(self)
523
+ dialog.exec()
524
+
477
525
  def _on_settings(self):
478
526
  """Show settings dialog."""
479
527
  dialog = SettingsDialog(self.theme_engine, self.current_theme, self)
@@ -26,121 +26,140 @@ class ItemType(Enum):
26
26
  SESSION = auto()
27
27
 
28
28
 
29
+ class DragDropTreeWidget(QTreeWidget):
30
+ """
31
+ QTreeWidget subclass that emits a signal after internal drag-drop operations.
32
+ """
33
+
34
+ items_moved = pyqtSignal() # Emitted after a drop completes
35
+
36
+ def __init__(self, parent=None):
37
+ super().__init__(parent)
38
+
39
+ def dropEvent(self, event):
40
+ """Handle drop - let Qt do the visual move, then signal for persistence."""
41
+ # Let Qt handle the visual rearrangement
42
+ super().dropEvent(event)
43
+ # Signal that items have moved and need persistence
44
+ self.items_moved.emit()
45
+
46
+
29
47
  class SessionTreeWidget(QWidget):
30
48
  """
31
49
  Tree-based session browser with filtering.
32
-
50
+
33
51
  Signals:
34
52
  connect_requested(session, mode): Emitted when user wants to connect
35
53
  session_selected(session): Emitted when selection changes
36
54
  """
37
-
55
+
38
56
  # Connect modes
39
57
  MODE_TAB = "tab"
40
58
  MODE_WINDOW = "window"
41
59
  MODE_QUICK = "quick"
42
-
60
+
43
61
  # Signals
44
62
  connect_requested = pyqtSignal(object, str) # (SavedSession, mode)
45
63
  session_selected = pyqtSignal(object) # SavedSession or None
46
64
  quick_connect_requested = pyqtSignal() # For quick connect dialog
47
-
65
+
48
66
  def __init__(self, store: SessionStore = None, parent: QWidget = None):
49
67
  super().__init__(parent)
50
68
  self.store = store or SessionStore()
51
-
69
+
52
70
  self._filter_timer = QTimer()
53
71
  self._filter_timer.setSingleShot(True)
54
72
  self._filter_timer.timeout.connect(self._apply_filter)
55
-
73
+
56
74
  self._setup_ui()
57
75
  self.refresh()
58
-
76
+
59
77
  def _setup_ui(self) -> None:
60
78
  """Build the UI."""
61
79
  layout = QVBoxLayout(self)
62
80
  layout.setContentsMargins(0, 0, 0, 0)
63
81
  layout.setSpacing(4)
64
-
82
+
65
83
  # Toolbar row
66
84
  toolbar = QHBoxLayout()
67
85
  toolbar.setSpacing(4)
68
-
86
+
69
87
  # Filter input
70
88
  self._filter_input = QLineEdit()
71
89
  self._filter_input.setPlaceholderText("Filter sessions...")
72
90
  self._filter_input.setClearButtonEnabled(True)
73
91
  self._filter_input.textChanged.connect(self._on_filter_changed)
74
92
  toolbar.addWidget(self._filter_input, 1)
75
-
93
+
76
94
  # Quick connect button
77
95
  self._quick_btn = QPushButton("Quick Connect")
78
96
  self._quick_btn.clicked.connect(self.quick_connect_requested.emit)
79
97
  toolbar.addWidget(self._quick_btn)
80
-
98
+
81
99
  layout.addLayout(toolbar)
82
-
83
- # Tree widget
84
- self._tree = QTreeWidget()
100
+
101
+ # Tree widget (using our custom subclass)
102
+ self._tree = DragDropTreeWidget()
85
103
  self._tree.setHeaderHidden(True)
86
104
  self._tree.setRootIsDecorated(True)
87
105
  self._tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
88
106
  self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
89
107
  self._tree.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
90
108
  self._tree.setAnimated(True)
91
-
109
+
92
110
  # Signals
93
111
  self._tree.itemDoubleClicked.connect(self._on_double_click)
94
112
  self._tree.itemSelectionChanged.connect(self._on_selection_changed)
95
113
  self._tree.customContextMenuRequested.connect(self._show_context_menu)
96
114
  self._tree.itemExpanded.connect(self._on_item_expanded)
97
115
  self._tree.itemCollapsed.connect(self._on_item_collapsed)
98
-
116
+ self._tree.items_moved.connect(self._persist_tree_state) # Handle drag-drop
117
+
99
118
  layout.addWidget(self._tree)
100
-
119
+
101
120
  # Action buttons row
102
121
  btn_row = QHBoxLayout()
103
122
  btn_row.setSpacing(4)
104
-
123
+
105
124
  self._connect_tab_btn = QPushButton("Connect")
106
125
  self._connect_tab_btn.setToolTip("Connect in new tab")
107
126
  self._connect_tab_btn.clicked.connect(lambda: self._connect_selected(self.MODE_TAB))
108
127
  self._connect_tab_btn.setEnabled(False)
109
128
  btn_row.addWidget(self._connect_tab_btn)
110
-
129
+
111
130
  self._connect_win_btn = QPushButton("New")
112
131
  self._connect_win_btn.setToolTip("Connect in separate window")
113
132
  self._connect_win_btn.clicked.connect(lambda: self._connect_selected(self.MODE_WINDOW))
114
133
  self._connect_win_btn.setEnabled(False)
115
134
  btn_row.addWidget(self._connect_win_btn)
116
-
135
+
117
136
  btn_row.addStretch()
118
-
137
+
119
138
  self._add_btn = QPushButton("+")
120
139
  self._add_btn.setFixedWidth(32)
121
140
  self._add_btn.setToolTip("Add session or folder")
122
141
  self._add_btn.clicked.connect(self._show_add_menu)
123
142
  btn_row.addWidget(self._add_btn)
124
-
143
+
125
144
  layout.addLayout(btn_row)
126
-
145
+
127
146
  # -------------------------------------------------------------------------
128
147
  # Public API
129
148
  # -------------------------------------------------------------------------
130
-
149
+
131
150
  def refresh(self) -> None:
132
151
  """Reload tree from store."""
133
152
  self._tree.clear()
134
153
  tree_data = self.store.get_tree()
135
-
154
+
136
155
  # Build folder lookup
137
156
  folder_items: dict[int, QTreeWidgetItem] = {}
138
-
157
+
139
158
  # First pass: create all folder items
140
159
  for folder in tree_data["folders"]:
141
160
  item = self._create_folder_item(folder)
142
161
  folder_items[folder.id] = item
143
-
162
+
144
163
  # Second pass: parent folders correctly
145
164
  for folder in tree_data["folders"]:
146
165
  item = folder_items[folder.id]
@@ -148,10 +167,10 @@ class SessionTreeWidget(QWidget):
148
167
  folder_items[folder.parent_id].addChild(item)
149
168
  else:
150
169
  self._tree.addTopLevelItem(item)
151
-
170
+
152
171
  # Restore expanded state
153
172
  item.setExpanded(folder.expanded)
154
-
173
+
155
174
  # Add sessions
156
175
  for session in tree_data["sessions"]:
157
176
  item = self._create_session_item(session)
@@ -159,9 +178,9 @@ class SessionTreeWidget(QWidget):
159
178
  folder_items[session.folder_id].addChild(item)
160
179
  else:
161
180
  self._tree.addTopLevelItem(item)
162
-
181
+
163
182
  self._apply_filter()
164
-
183
+
165
184
  def get_selected_session(self) -> Optional[SavedSession]:
166
185
  """Get currently selected session, or None."""
167
186
  items = self._tree.selectedItems()
@@ -172,17 +191,17 @@ class SessionTreeWidget(QWidget):
172
191
  session_id = item.data(0, ROLE_ITEM_ID)
173
192
  return self.store.get_session(session_id)
174
193
  return None
175
-
194
+
176
195
  def select_session(self, session_id: int) -> None:
177
196
  """Select a session by ID."""
178
197
  item = self._find_session_item(session_id)
179
198
  if item:
180
199
  self._tree.setCurrentItem(item)
181
-
200
+
182
201
  # -------------------------------------------------------------------------
183
202
  # Item creation
184
203
  # -------------------------------------------------------------------------
185
-
204
+
186
205
  def _create_folder_item(self, folder: SessionFolder) -> QTreeWidgetItem:
187
206
  """Create tree item for a folder."""
188
207
  item = QTreeWidgetItem()
@@ -190,31 +209,32 @@ class SessionTreeWidget(QWidget):
190
209
  item.setData(0, ROLE_ITEM_TYPE, ItemType.FOLDER)
191
210
  item.setData(0, ROLE_ITEM_ID, folder.id)
192
211
  item.setFlags(
193
- item.flags() |
212
+ item.flags() |
213
+ Qt.ItemFlag.ItemIsDragEnabled | # Folders can be dragged too
194
214
  Qt.ItemFlag.ItemIsDropEnabled
195
215
  )
196
216
  return item
197
-
217
+
198
218
  def _create_session_item(self, session: SavedSession) -> QTreeWidgetItem:
199
219
  """Create tree item for a session."""
200
220
  item = QTreeWidgetItem()
201
-
221
+
202
222
  # Display text
203
223
  display = f"🖥 {session.name}"
204
224
  if session.description:
205
225
  display += f" ({session.description})"
206
226
  item.setText(0, display)
207
227
  item.setToolTip(0, f"{session.hostname}:{session.port}")
208
-
228
+
209
229
  item.setData(0, ROLE_ITEM_TYPE, ItemType.SESSION)
210
230
  item.setData(0, ROLE_ITEM_ID, session.id)
211
231
  item.setFlags(
212
- item.flags() |
232
+ item.flags() |
213
233
  Qt.ItemFlag.ItemIsDragEnabled |
214
234
  Qt.ItemFlag.ItemNeverHasChildren
215
235
  )
216
236
  return item
217
-
237
+
218
238
  def _find_session_item(self, session_id: int) -> Optional[QTreeWidgetItem]:
219
239
  """Find tree item for a session ID."""
220
240
  iterator = self._tree_iterator()
@@ -223,7 +243,7 @@ class SessionTreeWidget(QWidget):
223
243
  item.data(0, ROLE_ITEM_ID) == session_id):
224
244
  return item
225
245
  return None
226
-
246
+
227
247
  def _tree_iterator(self):
228
248
  """Iterate all items in tree."""
229
249
  def recurse(parent):
@@ -231,11 +251,74 @@ class SessionTreeWidget(QWidget):
231
251
  child = parent.child(i)
232
252
  yield child
233
253
  yield from recurse(child)
234
-
254
+
235
255
  for i in range(self._tree.topLevelItemCount()):
236
256
  item = self._tree.topLevelItem(i)
237
257
  yield item
238
258
  yield from recurse(item)
259
+
260
+ # -------------------------------------------------------------------------
261
+ # Drag-drop persistence
262
+ # -------------------------------------------------------------------------
263
+
264
+ def _persist_tree_state(self) -> None:
265
+ """
266
+ Persist the current tree state to the store after drag-drop.
267
+
268
+ Walks the visual tree and updates folder_id/parent_id and positions
269
+ to match the current visual arrangement.
270
+ """
271
+ def get_folder_id_for_item(item: QTreeWidgetItem) -> Optional[int]:
272
+ """Get the folder ID that contains this item, or None for root."""
273
+ parent = item.parent()
274
+ if parent is None:
275
+ return None
276
+ # Parent should be a folder
277
+ if parent.data(0, ROLE_ITEM_TYPE) == ItemType.FOLDER:
278
+ return parent.data(0, ROLE_ITEM_ID)
279
+ return None
280
+
281
+ def process_children(parent_item, parent_folder_id: Optional[int]) -> None:
282
+ """Process all children of a parent (either root or folder)."""
283
+ if parent_item is None:
284
+ # Processing root level
285
+ count = self._tree.topLevelItemCount()
286
+ for pos in range(count):
287
+ item = self._tree.topLevelItem(pos)
288
+ process_item(item, None, pos)
289
+ else:
290
+ # Processing folder children
291
+ count = parent_item.childCount()
292
+ for pos in range(count):
293
+ item = parent_item.child(pos)
294
+ process_item(item, parent_folder_id, pos)
295
+
296
+ def process_item(item: QTreeWidgetItem, parent_folder_id: Optional[int], position: int) -> None:
297
+ """Process a single item - update its position and parent."""
298
+ item_type = item.data(0, ROLE_ITEM_TYPE)
299
+ item_id = item.data(0, ROLE_ITEM_ID)
300
+
301
+ if item_type == ItemType.SESSION:
302
+ # Update session's folder and position
303
+ session = self.store.get_session(item_id)
304
+ if session and (session.folder_id != parent_folder_id or session.position != position):
305
+ session.folder_id = parent_folder_id
306
+ session.position = position
307
+ self.store.update_session(session)
308
+
309
+ elif item_type == ItemType.FOLDER:
310
+ # Update folder's parent and position
311
+ folder = self.store.get_folder(item_id)
312
+ if folder and (folder.parent_id != parent_folder_id or folder.position != position):
313
+ folder.parent_id = parent_folder_id
314
+ folder.position = position
315
+ self.store.update_folder(folder)
316
+
317
+ # Recursively process folder's children
318
+ process_children(item, item_id)
319
+
320
+ # Start processing from root level
321
+ process_children(None, None)
239
322
 
240
323
  # -------------------------------------------------------------------------
241
324
  # Filtering