ntermqt 0.1.3__py3-none-any.whl → 0.1.5__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.
nterm/__main__.py CHANGED
@@ -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)
nterm/manager/tree.py CHANGED
@@ -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
File without changes