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.
- {ntermqt-0.1.3/ntermqt.egg-info → ntermqt-0.1.6}/PKG-INFO +46 -15
- {ntermqt-0.1.3 → ntermqt-0.1.6}/README.md +41 -10
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/__main__.py +48 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/tree.py +125 -42
- ntermqt-0.1.6/nterm/parser/api_help_dialog.py +607 -0
- ntermqt-0.1.6/nterm/parser/ntc_download_dialog.py +372 -0
- ntermqt-0.1.6/nterm/parser/tfsm_engine.py +246 -0
- ntermqt-0.1.6/nterm/parser/tfsm_fire.py +237 -0
- ntermqt-0.1.6/nterm/parser/tfsm_fire_tester.py +2329 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/scripting/__init__.py +8 -6
- ntermqt-0.1.6/nterm/scripting/api.py +1354 -0
- ntermqt-0.1.6/nterm/scripting/repl.py +406 -0
- ntermqt-0.1.6/nterm/scripting/repl_interactive.py +418 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/local_terminal.py +1 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/bridge.py +10 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.html +9 -4
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.js +14 -1
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/widget.py +73 -2
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/engine.py +45 -0
- ntermqt-0.1.6/nterm/theme/themes/clean.yaml +0 -0
- ntermqt-0.1.6/nterm/theme/themes/nord_hybrid.yaml +43 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/store.py +3 -3
- {ntermqt-0.1.3 → ntermqt-0.1.6/ntermqt.egg-info}/PKG-INFO +46 -15
- {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/SOURCES.txt +9 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/requires.txt +4 -5
- {ntermqt-0.1.3 → ntermqt-0.1.6}/pyproject.toml +7 -4
- ntermqt-0.1.3/nterm/scripting/api.py +0 -447
- {ntermqt-0.1.3 → ntermqt-0.1.6}/MANIFEST.in +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/askpass/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/askpass/server.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/config.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/connection/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/connection/profile.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/connect_dialog.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/editor.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/io.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/models.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/manager/settings.py +0 -0
- /ntermqt-0.1.3/nterm/theme/themes/clean.yaml → /ntermqt-0.1.6/nterm/parser/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/resources.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/scripting/cli.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/askpass_ssh.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/base.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/interactive_ssh.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/pty_transport.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/session/ssh.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.css +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.min.js +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/stylesheet.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/default.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/dracula.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_dark.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_light.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_light.yaml +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/__init__.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/credential_manager.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/keychain.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/manager_ui.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/profile.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/nterm/vault/resolver.py +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/dependency_links.txt +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/entry_points.txt +0 -0
- {ntermqt-0.1.3 → ntermqt-0.1.6}/ntermqt.egg-info/top_level.txt +0 -0
- {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
|
+
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
|
-
-
|
|
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
|

|
|
111
113
|
|
|
114
|
+

|
|
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
|
-
|
|
313
|
+
nterm includes 12 built-in themes covering dark, light, and hybrid styles.
|
|
314
|
+
|
|
315
|
+
### Built-in Themes
|
|
310
316
|
|
|
311
317
|
```python
|
|
312
|
-
|
|
313
|
-
Theme.
|
|
314
|
-
Theme.
|
|
315
|
-
Theme.
|
|
316
|
-
Theme.
|
|
317
|
-
Theme.
|
|
318
|
-
Theme.
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|

|
|
66
68
|
|
|
69
|
+

|
|
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
|
-
|
|
268
|
+
nterm includes 12 built-in themes covering dark, light, and hybrid styles.
|
|
269
|
+
|
|
270
|
+
### Built-in Themes
|
|
265
271
|
|
|
266
272
|
```python
|
|
267
|
-
|
|
268
|
-
Theme.
|
|
269
|
-
Theme.
|
|
270
|
-
Theme.
|
|
271
|
-
Theme.
|
|
272
|
-
Theme.
|
|
273
|
-
Theme.
|
|
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
|
-
|
|
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 =
|
|
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
|