ntermqt 0.1.3__tar.gz → 0.1.6__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 (75) hide show
  1. {ntermqt-0.1.3/ntermqt.egg-info → ntermqt-0.1.6}/PKG-INFO +46 -15
  2. {ntermqt-0.1.3 → ntermqt-0.1.6}/README.md +41 -10
  3. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/__main__.py +48 -0
  4. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/tree.py +125 -42
  5. ntermqt-0.1.6/nterm/parser/api_help_dialog.py +607 -0
  6. ntermqt-0.1.6/nterm/parser/ntc_download_dialog.py +372 -0
  7. ntermqt-0.1.6/nterm/parser/tfsm_engine.py +246 -0
  8. ntermqt-0.1.6/nterm/parser/tfsm_fire.py +237 -0
  9. ntermqt-0.1.6/nterm/parser/tfsm_fire_tester.py +2329 -0
  10. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/scripting/__init__.py +8 -6
  11. ntermqt-0.1.6/nterm/scripting/api.py +1354 -0
  12. ntermqt-0.1.6/nterm/scripting/repl.py +406 -0
  13. ntermqt-0.1.6/nterm/scripting/repl_interactive.py +418 -0
  14. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/local_terminal.py +1 -0
  15. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/bridge.py +10 -0
  16. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.html +9 -4
  17. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.js +14 -1
  18. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/widget.py +73 -2
  19. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/engine.py +45 -0
  20. ntermqt-0.1.6/nterm/theme/themes/clean.yaml +0 -0
  21. ntermqt-0.1.6/nterm/theme/themes/nord_hybrid.yaml +43 -0
  22. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/store.py +3 -3
  23. {ntermqt-0.1.3 → ntermqt-0.1.6/ntermqt.egg-info}/PKG-INFO +46 -15
  24. {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/SOURCES.txt +9 -0
  25. {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/requires.txt +4 -5
  26. {ntermqt-0.1.3 → ntermqt-0.1.6}/pyproject.toml +7 -4
  27. ntermqt-0.1.3/nterm/scripting/api.py +0 -447
  28. {ntermqt-0.1.3 → ntermqt-0.1.6}/MANIFEST.in +0 -0
  29. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/__init__.py +0 -0
  30. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/askpass/__init__.py +0 -0
  31. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/askpass/server.py +0 -0
  32. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/config.py +0 -0
  33. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/connection/__init__.py +0 -0
  34. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/connection/profile.py +0 -0
  35. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/__init__.py +0 -0
  36. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/connect_dialog.py +0 -0
  37. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/editor.py +0 -0
  38. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/io.py +0 -0
  39. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/models.py +0 -0
  40. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/settings.py +0 -0
  41. /ntermqt-0.1.3/nterm/theme/themes/clean.yaml → /ntermqt-0.1.6/nterm/parser/__init__.py +0 -0
  42. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/resources.py +0 -0
  43. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/scripting/cli.py +0 -0
  44. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/__init__.py +0 -0
  45. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/askpass_ssh.py +0 -0
  46. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/base.py +0 -0
  47. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/interactive_ssh.py +0 -0
  48. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/pty_transport.py +0 -0
  49. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/ssh.py +0 -0
  50. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/__init__.py +0 -0
  51. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
  52. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
  53. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
  54. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.css +0 -0
  55. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.min.js +0 -0
  56. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/__init__.py +0 -0
  57. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/stylesheet.py +0 -0
  58. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/default.yaml +0 -0
  59. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/dracula.yaml +0 -0
  60. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_dark.yaml +0 -0
  61. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
  62. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_light.yaml +0 -0
  63. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
  64. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
  65. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_light.yaml +0 -0
  66. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/__init__.py +0 -0
  67. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/credential_manager.py +0 -0
  68. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/keychain.py +0 -0
  69. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/manager_ui.py +0 -0
  70. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/profile.py +0 -0
  71. {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/resolver.py +0 -0
  72. {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/dependency_links.txt +0 -0
  73. {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/entry_points.txt +0 -0
  74. {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/top_level.txt +0 -0
  75. {ntermqt-0.1.3 → ntermqt-0.1.6}/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.6
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,22 @@ 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
32
+ Requires-Dist: rich>=14.0.0
29
33
  Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
30
34
  Requires-Dist: pywinpty>=2.0.0; sys_platform == "win32"
31
35
  Provides-Extra: keyring
32
36
  Requires-Dist: keyring>=24.0.0; extra == "keyring"
33
- Provides-Extra: scripting
34
- Requires-Dist: ipython>=8.0.0; extra == "scripting"
35
37
  Provides-Extra: dev
36
38
  Requires-Dist: pytest; extra == "dev"
37
39
  Requires-Dist: black; extra == "dev"
38
40
  Requires-Dist: pyinstaller; extra == "dev"
39
41
  Requires-Dist: build; extra == "dev"
40
42
  Requires-Dist: twine; extra == "dev"
41
- Requires-Dist: ipython>=8.0.0; extra == "dev"
42
43
  Provides-Extra: all
43
44
  Requires-Dist: keyring>=24.0.0; extra == "all"
44
- Requires-Dist: ipython>=8.0.0; extra == "all"
45
45
 
46
46
  # nterm
47
47
 
@@ -59,9 +59,11 @@ Built for managing hundreds of devices through bastion hosts with hardware secur
59
59
 
60
60
  **Terminal**
61
61
  - xterm.js rendering via QWebEngineView — full VT100/ANSI support
62
- - Built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox (dark/light/hybrid)
62
+ - 12 built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox, Enterprise variants
63
+ - Hybrid themes: dark UI chrome with light terminal for readability
63
64
  - Custom YAML themes with independent terminal and UI colors
64
65
  - Tab or window per session — pop sessions to separate windows
66
+ - Session capture to file (clean text, ANSI stripped)
65
67
  - Unicode, emoji, box-drawing characters
66
68
 
67
69
  **Authentication**
@@ -109,6 +111,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
109
111
 
110
112
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
111
113
 
114
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
115
+
112
116
  The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
113
117
 
114
118
  ```python
@@ -306,19 +310,33 @@ session.connect()
306
310
 
307
311
  ## Themes
308
312
 
309
- ### Built-in
313
+ nterm includes 12 built-in themes covering dark, light, and hybrid styles.
314
+
315
+ ### Built-in Themes
310
316
 
311
317
  ```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
318
+ # Dark themes
319
+ Theme.default() # Catppuccin Mocha
320
+ Theme.dracula() # Dracula
321
+ Theme.nord() # Nord
322
+ Theme.solarized_dark() # Solarized Dark
323
+ Theme.gruvbox_dark() # Gruvbox Dark
324
+ Theme.enterprise_dark() # Microsoft-inspired dark
325
+
326
+ # Light themes
327
+ Theme.gruvbox_light() # Gruvbox Light
328
+ Theme.enterprise_light() # Microsoft-inspired light
329
+ Theme.clean() # Warm paper tones
330
+
331
+ # Hybrid themes (dark UI + light terminal)
332
+ Theme.gruvbox_hybrid() # Gruvbox dark chrome, light terminal
333
+ Theme.nord_hybrid() # Nord polar night chrome, snow storm terminal
334
+ Theme.enterprise_hybrid() # VS Code-style dark/light split
319
335
  ```
320
336
 
321
- ### Custom YAML
337
+ **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.
338
+
339
+ ### Custom YAML Themes
322
340
 
323
341
  ```yaml
324
342
  # ~/.nterm/themes/my-theme.yaml
@@ -347,6 +365,19 @@ accent_color: "#7aa2f7"
347
365
 
348
366
  ---
349
367
 
368
+ ## Session Capture
369
+
370
+ Capture session output to a file for documentation, auditing, or extracting config snippets.
371
+
372
+ **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.
373
+
374
+ - Per-session capture (each tab independent)
375
+ - File dialog for save location
376
+ - Menu shows active capture filename
377
+ - Auto-stops when session closes
378
+
379
+ ---
380
+
350
381
  ## Jump Hosts
351
382
 
352
383
  ```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**
@@ -64,6 +66,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
64
66
 
65
67
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
66
68
 
69
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
70
+
67
71
  The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
68
72
 
69
73
  ```python
@@ -261,19 +265,33 @@ session.connect()
261
265
 
262
266
  ## Themes
263
267
 
264
- ### Built-in
268
+ nterm includes 12 built-in themes covering dark, light, and hybrid styles.
269
+
270
+ ### Built-in Themes
265
271
 
266
272
  ```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
273
+ # Dark themes
274
+ Theme.default() # Catppuccin Mocha
275
+ Theme.dracula() # Dracula
276
+ Theme.nord() # Nord
277
+ Theme.solarized_dark() # Solarized Dark
278
+ Theme.gruvbox_dark() # Gruvbox Dark
279
+ Theme.enterprise_dark() # Microsoft-inspired dark
280
+
281
+ # Light themes
282
+ Theme.gruvbox_light() # Gruvbox Light
283
+ Theme.enterprise_light() # Microsoft-inspired light
284
+ Theme.clean() # Warm paper tones
285
+
286
+ # Hybrid themes (dark UI + light terminal)
287
+ Theme.gruvbox_hybrid() # Gruvbox dark chrome, light terminal
288
+ Theme.nord_hybrid() # Nord polar night chrome, snow storm terminal
289
+ Theme.enterprise_hybrid() # VS Code-style dark/light split
274
290
  ```
275
291
 
276
- ### Custom YAML
292
+ **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.
293
+
294
+ ### Custom YAML Themes
277
295
 
278
296
  ```yaml
279
297
  # ~/.nterm/themes/my-theme.yaml
@@ -302,6 +320,19 @@ accent_color: "#7aa2f7"
302
320
 
303
321
  ---
304
322
 
323
+ ## Session Capture
324
+
325
+ Capture session output to a file for documentation, auditing, or extracting config snippets.
326
+
327
+ **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.
328
+
329
+ - Per-session capture (each tab independent)
330
+ - File dialog for save location
331
+ - Menu shows active capture filename
332
+ - Auto-stops when session closes
333
+
334
+ ---
335
+
305
336
  ## Jump Hosts
306
337
 
307
338
  ```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