ntermqt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nterm/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/manager/tree.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session tree widget with filtering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
|
|
9
|
+
from PyQt6.QtWidgets import (
|
|
10
|
+
QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem,
|
|
11
|
+
QLineEdit, QPushButton, QMenu, QInputDialog, QMessageBox,
|
|
12
|
+
QAbstractItemView, QSizePolicy
|
|
13
|
+
)
|
|
14
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
|
15
|
+
from PyQt6.QtGui import QIcon, QAction
|
|
16
|
+
|
|
17
|
+
from .models import SessionStore, SavedSession, SessionFolder
|
|
18
|
+
|
|
19
|
+
# Item data roles
|
|
20
|
+
ROLE_ITEM_TYPE = Qt.ItemDataRole.UserRole
|
|
21
|
+
ROLE_ITEM_ID = Qt.ItemDataRole.UserRole + 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ItemType(Enum):
|
|
25
|
+
FOLDER = auto()
|
|
26
|
+
SESSION = auto()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionTreeWidget(QWidget):
|
|
30
|
+
"""
|
|
31
|
+
Tree-based session browser with filtering.
|
|
32
|
+
|
|
33
|
+
Signals:
|
|
34
|
+
connect_requested(session, mode): Emitted when user wants to connect
|
|
35
|
+
session_selected(session): Emitted when selection changes
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Connect modes
|
|
39
|
+
MODE_TAB = "tab"
|
|
40
|
+
MODE_WINDOW = "window"
|
|
41
|
+
MODE_QUICK = "quick"
|
|
42
|
+
|
|
43
|
+
# Signals
|
|
44
|
+
connect_requested = pyqtSignal(object, str) # (SavedSession, mode)
|
|
45
|
+
session_selected = pyqtSignal(object) # SavedSession or None
|
|
46
|
+
quick_connect_requested = pyqtSignal() # For quick connect dialog
|
|
47
|
+
|
|
48
|
+
def __init__(self, store: SessionStore = None, parent: QWidget = None):
|
|
49
|
+
super().__init__(parent)
|
|
50
|
+
self.store = store or SessionStore()
|
|
51
|
+
|
|
52
|
+
self._filter_timer = QTimer()
|
|
53
|
+
self._filter_timer.setSingleShot(True)
|
|
54
|
+
self._filter_timer.timeout.connect(self._apply_filter)
|
|
55
|
+
|
|
56
|
+
self._setup_ui()
|
|
57
|
+
self.refresh()
|
|
58
|
+
|
|
59
|
+
def _setup_ui(self) -> None:
|
|
60
|
+
"""Build the UI."""
|
|
61
|
+
layout = QVBoxLayout(self)
|
|
62
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
63
|
+
layout.setSpacing(4)
|
|
64
|
+
|
|
65
|
+
# Toolbar row
|
|
66
|
+
toolbar = QHBoxLayout()
|
|
67
|
+
toolbar.setSpacing(4)
|
|
68
|
+
|
|
69
|
+
# Filter input
|
|
70
|
+
self._filter_input = QLineEdit()
|
|
71
|
+
self._filter_input.setPlaceholderText("Filter sessions...")
|
|
72
|
+
self._filter_input.setClearButtonEnabled(True)
|
|
73
|
+
self._filter_input.textChanged.connect(self._on_filter_changed)
|
|
74
|
+
toolbar.addWidget(self._filter_input, 1)
|
|
75
|
+
|
|
76
|
+
# Quick connect button
|
|
77
|
+
self._quick_btn = QPushButton("Quick Connect")
|
|
78
|
+
self._quick_btn.clicked.connect(self.quick_connect_requested.emit)
|
|
79
|
+
toolbar.addWidget(self._quick_btn)
|
|
80
|
+
|
|
81
|
+
layout.addLayout(toolbar)
|
|
82
|
+
|
|
83
|
+
# Tree widget
|
|
84
|
+
self._tree = QTreeWidget()
|
|
85
|
+
self._tree.setHeaderHidden(True)
|
|
86
|
+
self._tree.setRootIsDecorated(True)
|
|
87
|
+
self._tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
88
|
+
self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
89
|
+
self._tree.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
|
90
|
+
self._tree.setAnimated(True)
|
|
91
|
+
|
|
92
|
+
# Signals
|
|
93
|
+
self._tree.itemDoubleClicked.connect(self._on_double_click)
|
|
94
|
+
self._tree.itemSelectionChanged.connect(self._on_selection_changed)
|
|
95
|
+
self._tree.customContextMenuRequested.connect(self._show_context_menu)
|
|
96
|
+
self._tree.itemExpanded.connect(self._on_item_expanded)
|
|
97
|
+
self._tree.itemCollapsed.connect(self._on_item_collapsed)
|
|
98
|
+
|
|
99
|
+
layout.addWidget(self._tree)
|
|
100
|
+
|
|
101
|
+
# Action buttons row
|
|
102
|
+
btn_row = QHBoxLayout()
|
|
103
|
+
btn_row.setSpacing(4)
|
|
104
|
+
|
|
105
|
+
self._connect_tab_btn = QPushButton("Connect")
|
|
106
|
+
self._connect_tab_btn.setToolTip("Connect in new tab")
|
|
107
|
+
self._connect_tab_btn.clicked.connect(lambda: self._connect_selected(self.MODE_TAB))
|
|
108
|
+
self._connect_tab_btn.setEnabled(False)
|
|
109
|
+
btn_row.addWidget(self._connect_tab_btn)
|
|
110
|
+
|
|
111
|
+
self._connect_win_btn = QPushButton("New")
|
|
112
|
+
self._connect_win_btn.setToolTip("Connect in separate window")
|
|
113
|
+
self._connect_win_btn.clicked.connect(lambda: self._connect_selected(self.MODE_WINDOW))
|
|
114
|
+
self._connect_win_btn.setEnabled(False)
|
|
115
|
+
btn_row.addWidget(self._connect_win_btn)
|
|
116
|
+
|
|
117
|
+
btn_row.addStretch()
|
|
118
|
+
|
|
119
|
+
self._add_btn = QPushButton("+")
|
|
120
|
+
self._add_btn.setFixedWidth(32)
|
|
121
|
+
self._add_btn.setToolTip("Add session or folder")
|
|
122
|
+
self._add_btn.clicked.connect(self._show_add_menu)
|
|
123
|
+
btn_row.addWidget(self._add_btn)
|
|
124
|
+
|
|
125
|
+
layout.addLayout(btn_row)
|
|
126
|
+
|
|
127
|
+
# -------------------------------------------------------------------------
|
|
128
|
+
# Public API
|
|
129
|
+
# -------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def refresh(self) -> None:
|
|
132
|
+
"""Reload tree from store."""
|
|
133
|
+
self._tree.clear()
|
|
134
|
+
tree_data = self.store.get_tree()
|
|
135
|
+
|
|
136
|
+
# Build folder lookup
|
|
137
|
+
folder_items: dict[int, QTreeWidgetItem] = {}
|
|
138
|
+
|
|
139
|
+
# First pass: create all folder items
|
|
140
|
+
for folder in tree_data["folders"]:
|
|
141
|
+
item = self._create_folder_item(folder)
|
|
142
|
+
folder_items[folder.id] = item
|
|
143
|
+
|
|
144
|
+
# Second pass: parent folders correctly
|
|
145
|
+
for folder in tree_data["folders"]:
|
|
146
|
+
item = folder_items[folder.id]
|
|
147
|
+
if folder.parent_id and folder.parent_id in folder_items:
|
|
148
|
+
folder_items[folder.parent_id].addChild(item)
|
|
149
|
+
else:
|
|
150
|
+
self._tree.addTopLevelItem(item)
|
|
151
|
+
|
|
152
|
+
# Restore expanded state
|
|
153
|
+
item.setExpanded(folder.expanded)
|
|
154
|
+
|
|
155
|
+
# Add sessions
|
|
156
|
+
for session in tree_data["sessions"]:
|
|
157
|
+
item = self._create_session_item(session)
|
|
158
|
+
if session.folder_id and session.folder_id in folder_items:
|
|
159
|
+
folder_items[session.folder_id].addChild(item)
|
|
160
|
+
else:
|
|
161
|
+
self._tree.addTopLevelItem(item)
|
|
162
|
+
|
|
163
|
+
self._apply_filter()
|
|
164
|
+
|
|
165
|
+
def get_selected_session(self) -> Optional[SavedSession]:
|
|
166
|
+
"""Get currently selected session, or None."""
|
|
167
|
+
items = self._tree.selectedItems()
|
|
168
|
+
if not items:
|
|
169
|
+
return None
|
|
170
|
+
item = items[0]
|
|
171
|
+
if item.data(0, ROLE_ITEM_TYPE) == ItemType.SESSION:
|
|
172
|
+
session_id = item.data(0, ROLE_ITEM_ID)
|
|
173
|
+
return self.store.get_session(session_id)
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def select_session(self, session_id: int) -> None:
|
|
177
|
+
"""Select a session by ID."""
|
|
178
|
+
item = self._find_session_item(session_id)
|
|
179
|
+
if item:
|
|
180
|
+
self._tree.setCurrentItem(item)
|
|
181
|
+
|
|
182
|
+
# -------------------------------------------------------------------------
|
|
183
|
+
# Item creation
|
|
184
|
+
# -------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _create_folder_item(self, folder: SessionFolder) -> QTreeWidgetItem:
|
|
187
|
+
"""Create tree item for a folder."""
|
|
188
|
+
item = QTreeWidgetItem()
|
|
189
|
+
item.setText(0, f"📁 {folder.name}")
|
|
190
|
+
item.setData(0, ROLE_ITEM_TYPE, ItemType.FOLDER)
|
|
191
|
+
item.setData(0, ROLE_ITEM_ID, folder.id)
|
|
192
|
+
item.setFlags(
|
|
193
|
+
item.flags() |
|
|
194
|
+
Qt.ItemFlag.ItemIsDropEnabled
|
|
195
|
+
)
|
|
196
|
+
return item
|
|
197
|
+
|
|
198
|
+
def _create_session_item(self, session: SavedSession) -> QTreeWidgetItem:
|
|
199
|
+
"""Create tree item for a session."""
|
|
200
|
+
item = QTreeWidgetItem()
|
|
201
|
+
|
|
202
|
+
# Display text
|
|
203
|
+
display = f"🖥 {session.name}"
|
|
204
|
+
if session.description:
|
|
205
|
+
display += f" ({session.description})"
|
|
206
|
+
item.setText(0, display)
|
|
207
|
+
item.setToolTip(0, f"{session.hostname}:{session.port}")
|
|
208
|
+
|
|
209
|
+
item.setData(0, ROLE_ITEM_TYPE, ItemType.SESSION)
|
|
210
|
+
item.setData(0, ROLE_ITEM_ID, session.id)
|
|
211
|
+
item.setFlags(
|
|
212
|
+
item.flags() |
|
|
213
|
+
Qt.ItemFlag.ItemIsDragEnabled |
|
|
214
|
+
Qt.ItemFlag.ItemNeverHasChildren
|
|
215
|
+
)
|
|
216
|
+
return item
|
|
217
|
+
|
|
218
|
+
def _find_session_item(self, session_id: int) -> Optional[QTreeWidgetItem]:
|
|
219
|
+
"""Find tree item for a session ID."""
|
|
220
|
+
iterator = self._tree_iterator()
|
|
221
|
+
for item in iterator:
|
|
222
|
+
if (item.data(0, ROLE_ITEM_TYPE) == ItemType.SESSION and
|
|
223
|
+
item.data(0, ROLE_ITEM_ID) == session_id):
|
|
224
|
+
return item
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def _tree_iterator(self):
|
|
228
|
+
"""Iterate all items in tree."""
|
|
229
|
+
def recurse(parent):
|
|
230
|
+
for i in range(parent.childCount()):
|
|
231
|
+
child = parent.child(i)
|
|
232
|
+
yield child
|
|
233
|
+
yield from recurse(child)
|
|
234
|
+
|
|
235
|
+
for i in range(self._tree.topLevelItemCount()):
|
|
236
|
+
item = self._tree.topLevelItem(i)
|
|
237
|
+
yield item
|
|
238
|
+
yield from recurse(item)
|
|
239
|
+
|
|
240
|
+
# -------------------------------------------------------------------------
|
|
241
|
+
# Filtering
|
|
242
|
+
# -------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def _on_filter_changed(self, text: str) -> None:
|
|
245
|
+
"""Handle filter text change (debounced)."""
|
|
246
|
+
self._filter_timer.start(150) # 150ms debounce
|
|
247
|
+
|
|
248
|
+
def _apply_filter(self) -> None:
|
|
249
|
+
"""Apply current filter to tree."""
|
|
250
|
+
query = self._filter_input.text().strip().lower()
|
|
251
|
+
|
|
252
|
+
if not query:
|
|
253
|
+
# Show everything
|
|
254
|
+
for item in self._tree_iterator():
|
|
255
|
+
item.setHidden(False)
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Hide non-matching items, but show folders with matching children
|
|
259
|
+
def process_item(item) -> bool:
|
|
260
|
+
"""Returns True if item or any child matches."""
|
|
261
|
+
item_type = item.data(0, ROLE_ITEM_TYPE)
|
|
262
|
+
|
|
263
|
+
if item_type == ItemType.SESSION:
|
|
264
|
+
session_id = item.data(0, ROLE_ITEM_ID)
|
|
265
|
+
session = self.store.get_session(session_id)
|
|
266
|
+
if session:
|
|
267
|
+
matches = (
|
|
268
|
+
query in session.name.lower() or
|
|
269
|
+
query in session.description.lower() or
|
|
270
|
+
query in session.hostname.lower()
|
|
271
|
+
)
|
|
272
|
+
item.setHidden(not matches)
|
|
273
|
+
return matches
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
elif item_type == ItemType.FOLDER:
|
|
277
|
+
# Check all children
|
|
278
|
+
any_child_visible = False
|
|
279
|
+
for i in range(item.childCount()):
|
|
280
|
+
if process_item(item.child(i)):
|
|
281
|
+
any_child_visible = True
|
|
282
|
+
|
|
283
|
+
item.setHidden(not any_child_visible)
|
|
284
|
+
if any_child_visible:
|
|
285
|
+
item.setExpanded(True)
|
|
286
|
+
return any_child_visible
|
|
287
|
+
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
for i in range(self._tree.topLevelItemCount()):
|
|
291
|
+
process_item(self._tree.topLevelItem(i))
|
|
292
|
+
|
|
293
|
+
# -------------------------------------------------------------------------
|
|
294
|
+
# Context menu
|
|
295
|
+
# -------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def _show_context_menu(self, pos) -> None:
|
|
298
|
+
"""Show right-click context menu."""
|
|
299
|
+
item = self._tree.itemAt(pos)
|
|
300
|
+
menu = QMenu(self)
|
|
301
|
+
|
|
302
|
+
if item:
|
|
303
|
+
item_type = item.data(0, ROLE_ITEM_TYPE)
|
|
304
|
+
|
|
305
|
+
if item_type == ItemType.SESSION:
|
|
306
|
+
session = self.get_selected_session()
|
|
307
|
+
if session:
|
|
308
|
+
menu.addAction("Connect in Tab",
|
|
309
|
+
lambda: self._connect_session(session, self.MODE_TAB))
|
|
310
|
+
menu.addAction("Connect in Window",
|
|
311
|
+
lambda: self._connect_session(session, self.MODE_WINDOW))
|
|
312
|
+
menu.addSeparator()
|
|
313
|
+
menu.addAction("Edit...", lambda: self._edit_session(session))
|
|
314
|
+
menu.addAction("Duplicate", lambda: self._duplicate_session(session))
|
|
315
|
+
menu.addSeparator()
|
|
316
|
+
menu.addAction("Delete", lambda: self._delete_session(session))
|
|
317
|
+
|
|
318
|
+
elif item_type == ItemType.FOLDER:
|
|
319
|
+
folder_id = item.data(0, ROLE_ITEM_ID)
|
|
320
|
+
folder = self.store.get_folder(folder_id)
|
|
321
|
+
if folder:
|
|
322
|
+
menu.addAction("New Session Here...",
|
|
323
|
+
lambda: self._add_session(folder_id))
|
|
324
|
+
menu.addAction("New Subfolder...",
|
|
325
|
+
lambda: self._add_folder(folder_id))
|
|
326
|
+
menu.addSeparator()
|
|
327
|
+
menu.addAction("Rename...", lambda: self._rename_folder(folder))
|
|
328
|
+
menu.addSeparator()
|
|
329
|
+
menu.addAction("Delete Folder", lambda: self._delete_folder(folder))
|
|
330
|
+
|
|
331
|
+
else:
|
|
332
|
+
# Clicked on empty space
|
|
333
|
+
menu.addAction("New Session...", lambda: self._add_session(None))
|
|
334
|
+
menu.addAction("New Folder...", lambda: self._add_folder(None))
|
|
335
|
+
|
|
336
|
+
if menu.actions():
|
|
337
|
+
menu.exec(self._tree.viewport().mapToGlobal(pos))
|
|
338
|
+
|
|
339
|
+
def _show_add_menu(self) -> None:
|
|
340
|
+
"""Show add button menu."""
|
|
341
|
+
menu = QMenu(self)
|
|
342
|
+
menu.addAction("New Session...", lambda: self._add_session(None))
|
|
343
|
+
menu.addAction("New Folder...", lambda: self._add_folder(None))
|
|
344
|
+
menu.exec(self._add_btn.mapToGlobal(self._add_btn.rect().bottomLeft()))
|
|
345
|
+
|
|
346
|
+
# -------------------------------------------------------------------------
|
|
347
|
+
# Actions
|
|
348
|
+
# -------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def _on_double_click(self, item: QTreeWidgetItem, column: int) -> None:
|
|
351
|
+
"""Handle double-click - connect to session."""
|
|
352
|
+
if item.data(0, ROLE_ITEM_TYPE) == ItemType.SESSION:
|
|
353
|
+
session = self.get_selected_session()
|
|
354
|
+
if session:
|
|
355
|
+
self._connect_session(session, self.MODE_TAB)
|
|
356
|
+
|
|
357
|
+
def _on_selection_changed(self) -> None:
|
|
358
|
+
"""Handle selection change."""
|
|
359
|
+
session = self.get_selected_session()
|
|
360
|
+
self._connect_tab_btn.setEnabled(session is not None)
|
|
361
|
+
self._connect_win_btn.setEnabled(session is not None)
|
|
362
|
+
self.session_selected.emit(session)
|
|
363
|
+
|
|
364
|
+
def _on_item_expanded(self, item: QTreeWidgetItem) -> None:
|
|
365
|
+
"""Save folder expanded state."""
|
|
366
|
+
if item.data(0, ROLE_ITEM_TYPE) == ItemType.FOLDER:
|
|
367
|
+
folder_id = item.data(0, ROLE_ITEM_ID)
|
|
368
|
+
folder = self.store.get_folder(folder_id)
|
|
369
|
+
if folder:
|
|
370
|
+
folder.expanded = True
|
|
371
|
+
self.store.update_folder(folder)
|
|
372
|
+
|
|
373
|
+
def _on_item_collapsed(self, item: QTreeWidgetItem) -> None:
|
|
374
|
+
"""Save folder collapsed state."""
|
|
375
|
+
if item.data(0, ROLE_ITEM_TYPE) == ItemType.FOLDER:
|
|
376
|
+
folder_id = item.data(0, ROLE_ITEM_ID)
|
|
377
|
+
folder = self.store.get_folder(folder_id)
|
|
378
|
+
if folder:
|
|
379
|
+
folder.expanded = False
|
|
380
|
+
self.store.update_folder(folder)
|
|
381
|
+
|
|
382
|
+
def _connect_selected(self, mode: str) -> None:
|
|
383
|
+
"""Connect to selected session."""
|
|
384
|
+
session = self.get_selected_session()
|
|
385
|
+
if session:
|
|
386
|
+
self._connect_session(session, mode)
|
|
387
|
+
|
|
388
|
+
def _connect_session(self, session: SavedSession, mode: str) -> None:
|
|
389
|
+
"""Emit connect request."""
|
|
390
|
+
self.store.record_connect(session.id)
|
|
391
|
+
self.connect_requested.emit(session, mode)
|
|
392
|
+
|
|
393
|
+
def _add_session(self, folder_id: int = None) -> None:
|
|
394
|
+
"""Add new session (opens editor)."""
|
|
395
|
+
from .editor import SessionEditorDialog
|
|
396
|
+
|
|
397
|
+
# Get credential names from parent if available
|
|
398
|
+
cred_names = []
|
|
399
|
+
parent = self.parent()
|
|
400
|
+
while parent:
|
|
401
|
+
if hasattr(parent, '_credential_names'):
|
|
402
|
+
cred_names = parent._credential_names
|
|
403
|
+
break
|
|
404
|
+
parent = parent.parent()
|
|
405
|
+
|
|
406
|
+
session = SavedSession(folder_id=folder_id)
|
|
407
|
+
dialog = SessionEditorDialog(session, cred_names, parent=self)
|
|
408
|
+
if dialog.exec():
|
|
409
|
+
session = dialog.get_session()
|
|
410
|
+
self.store.add_session(session)
|
|
411
|
+
self.refresh()
|
|
412
|
+
|
|
413
|
+
def _edit_session(self, session: SavedSession) -> None:
|
|
414
|
+
"""Edit existing session."""
|
|
415
|
+
from .editor import SessionEditorDialog
|
|
416
|
+
|
|
417
|
+
# Get credential names from parent if available
|
|
418
|
+
cred_names = []
|
|
419
|
+
parent = self.parent()
|
|
420
|
+
while parent:
|
|
421
|
+
if hasattr(parent, '_credential_names'):
|
|
422
|
+
cred_names = parent._credential_names
|
|
423
|
+
break
|
|
424
|
+
parent = parent.parent()
|
|
425
|
+
|
|
426
|
+
dialog = SessionEditorDialog(session, cred_names, parent=self)
|
|
427
|
+
if dialog.exec():
|
|
428
|
+
updated = dialog.get_session()
|
|
429
|
+
updated.id = session.id
|
|
430
|
+
self.store.update_session(updated)
|
|
431
|
+
self.refresh()
|
|
432
|
+
self.select_session(session.id)
|
|
433
|
+
|
|
434
|
+
def _duplicate_session(self, session: SavedSession) -> None:
|
|
435
|
+
"""Duplicate a session."""
|
|
436
|
+
new_session = SavedSession(
|
|
437
|
+
name=f"{session.name} (copy)",
|
|
438
|
+
description=session.description,
|
|
439
|
+
hostname=session.hostname,
|
|
440
|
+
port=session.port,
|
|
441
|
+
credential_name=session.credential_name,
|
|
442
|
+
folder_id=session.folder_id,
|
|
443
|
+
extras=session.extras.copy(),
|
|
444
|
+
)
|
|
445
|
+
new_id = self.store.add_session(new_session)
|
|
446
|
+
self.refresh()
|
|
447
|
+
self.select_session(new_id)
|
|
448
|
+
|
|
449
|
+
def _delete_session(self, session: SavedSession) -> None:
|
|
450
|
+
"""Delete a session with confirmation."""
|
|
451
|
+
reply = QMessageBox.question(
|
|
452
|
+
self,
|
|
453
|
+
"Delete Session",
|
|
454
|
+
f"Delete session '{session.name}'?",
|
|
455
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
456
|
+
QMessageBox.StandardButton.No
|
|
457
|
+
)
|
|
458
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
459
|
+
self.store.delete_session(session.id)
|
|
460
|
+
self.refresh()
|
|
461
|
+
|
|
462
|
+
def _add_folder(self, parent_id: int = None) -> None:
|
|
463
|
+
"""Add new folder."""
|
|
464
|
+
name, ok = QInputDialog.getText(
|
|
465
|
+
self, "New Folder", "Folder name:"
|
|
466
|
+
)
|
|
467
|
+
if ok and name.strip():
|
|
468
|
+
self.store.add_folder(name.strip(), parent_id)
|
|
469
|
+
self.refresh()
|
|
470
|
+
|
|
471
|
+
def _rename_folder(self, folder: SessionFolder) -> None:
|
|
472
|
+
"""Rename a folder."""
|
|
473
|
+
name, ok = QInputDialog.getText(
|
|
474
|
+
self, "Rename Folder", "Folder name:", text=folder.name
|
|
475
|
+
)
|
|
476
|
+
if ok and name.strip():
|
|
477
|
+
folder.name = name.strip()
|
|
478
|
+
self.store.update_folder(folder)
|
|
479
|
+
self.refresh()
|
|
480
|
+
|
|
481
|
+
def _delete_folder(self, folder: SessionFolder) -> None:
|
|
482
|
+
"""Delete a folder with confirmation."""
|
|
483
|
+
reply = QMessageBox.question(
|
|
484
|
+
self,
|
|
485
|
+
"Delete Folder",
|
|
486
|
+
f"Delete folder '{folder.name}'?\n\n"
|
|
487
|
+
"Sessions inside will be moved to the root level.",
|
|
488
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
489
|
+
QMessageBox.StandardButton.No
|
|
490
|
+
)
|
|
491
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
492
|
+
self.store.delete_folder(folder.id)
|
|
493
|
+
self.refresh()
|
nterm/resources.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Resource manager for development and installed package modes."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResourceManager:
|
|
7
|
+
_instance = None
|
|
8
|
+
|
|
9
|
+
def __new__(cls):
|
|
10
|
+
if cls._instance is None:
|
|
11
|
+
cls._instance = super().__new__(cls)
|
|
12
|
+
cls._instance._package_root = Path(__file__).parent
|
|
13
|
+
return cls._instance
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def dev_mode(self) -> bool:
|
|
17
|
+
"""True if running from source checkout."""
|
|
18
|
+
return (self._package_root.parent / "pyproject.toml").exists()
|
|
19
|
+
|
|
20
|
+
def get_path(self, *parts: str) -> Path:
|
|
21
|
+
"""Get absolute path to a package resource."""
|
|
22
|
+
path = self._package_root.joinpath(*parts)
|
|
23
|
+
if not path.exists():
|
|
24
|
+
raise FileNotFoundError(f"Resource not found: {path}")
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
def get_uri(self, *parts: str) -> str:
|
|
28
|
+
"""Get file:// URI for QWebEngineView."""
|
|
29
|
+
return self.get_path(*parts).as_uri()
|
|
30
|
+
|
|
31
|
+
def read_text(self, *parts: str) -> str:
|
|
32
|
+
return self.get_path(*parts).read_text(encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
def read_bytes(self, *parts: str) -> bytes:
|
|
35
|
+
return self.get_path(*parts).read_bytes()
|
|
36
|
+
|
|
37
|
+
# Convenience shortcuts
|
|
38
|
+
@property
|
|
39
|
+
def terminal_resources(self) -> Path:
|
|
40
|
+
return self.get_path("terminal", "resources")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def themes_dir(self) -> Path:
|
|
44
|
+
return self.get_path("theme", "themes")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Singleton instance
|
|
48
|
+
resources = ResourceManager()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session management - handles connection lifecycle and I/O.
|
|
3
|
+
|
|
4
|
+
Provides multiple session implementations:
|
|
5
|
+
|
|
6
|
+
- SSHSession: Paramiko-based, programmatic auth (password, key, agent)
|
|
7
|
+
- InteractiveSSHSession: Native ssh binary with PTY for full interactive auth
|
|
8
|
+
- AskpassSSHSession: Native ssh with SSH_ASKPASS for GUI prompts (recommended)
|
|
9
|
+
- HybridSSHSession: Interactive auth with ControlMaster for connection reuse
|
|
10
|
+
|
|
11
|
+
Choose based on your needs:
|
|
12
|
+
- Use SSHSession for automation with stored credentials
|
|
13
|
+
- Use AskpassSSHSession for GUI apps with YubiKey/MFA (recommended)
|
|
14
|
+
- Use InteractiveSSHSession for console-like terminal experience
|
|
15
|
+
- Use HybridSSHSession for interactive auth followed by programmatic control
|
|
16
|
+
|
|
17
|
+
For best results with GUI authentication, use AskpassSSHSession.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .base import (
|
|
21
|
+
Session,
|
|
22
|
+
SessionState,
|
|
23
|
+
SessionEvent,
|
|
24
|
+
DataReceived,
|
|
25
|
+
StateChanged,
|
|
26
|
+
InteractionRequired,
|
|
27
|
+
BannerReceived,
|
|
28
|
+
)
|
|
29
|
+
from .ssh import SSHSession
|
|
30
|
+
from .interactive_ssh import InteractiveSSHSession, HybridSSHSession
|
|
31
|
+
from .askpass_ssh import AskpassSSHSession
|
|
32
|
+
from .pty_transport import (
|
|
33
|
+
PTYTransport,
|
|
34
|
+
create_pty,
|
|
35
|
+
is_pty_available,
|
|
36
|
+
IS_WINDOWS,
|
|
37
|
+
HAS_PEXPECT,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Base classes
|
|
42
|
+
"Session",
|
|
43
|
+
"SessionState",
|
|
44
|
+
"SessionEvent",
|
|
45
|
+
"DataReceived",
|
|
46
|
+
"StateChanged",
|
|
47
|
+
"InteractionRequired",
|
|
48
|
+
"BannerReceived",
|
|
49
|
+
# Session implementations
|
|
50
|
+
"SSHSession",
|
|
51
|
+
"InteractiveSSHSession",
|
|
52
|
+
"AskpassSSHSession",
|
|
53
|
+
"HybridSSHSession",
|
|
54
|
+
# PTY support
|
|
55
|
+
"PTYTransport",
|
|
56
|
+
"create_pty",
|
|
57
|
+
"is_pty_available",
|
|
58
|
+
"IS_WINDOWS",
|
|
59
|
+
"HAS_PEXPECT",
|
|
60
|
+
]
|