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/terminal/widget.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyQt6 terminal widget using xterm.js.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot
|
|
13
|
+
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication
|
|
14
|
+
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
15
|
+
from PyQt6.QtWebEngineCore import QWebEngineSettings
|
|
16
|
+
from PyQt6.QtWebChannel import QWebChannel
|
|
17
|
+
|
|
18
|
+
from ..session.base import (
|
|
19
|
+
Session, SessionState, SessionEvent,
|
|
20
|
+
DataReceived, StateChanged, InteractionRequired, BannerReceived
|
|
21
|
+
)
|
|
22
|
+
from ..theme.engine import Theme
|
|
23
|
+
from .bridge import TerminalBridge
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
from nterm.resources import resources
|
|
28
|
+
|
|
29
|
+
# Default threshold for multiline paste warning
|
|
30
|
+
MULTILINE_PASTE_THRESHOLD = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TerminalWidget(QWidget):
|
|
34
|
+
"""
|
|
35
|
+
Themeable terminal widget.
|
|
36
|
+
|
|
37
|
+
Renders terminal via xterm.js in QWebEngineView.
|
|
38
|
+
Connects to a Session for I/O.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Public signals
|
|
42
|
+
session_state_changed = pyqtSignal(SessionState, str)
|
|
43
|
+
interaction_required = pyqtSignal(str, str) # prompt, type
|
|
44
|
+
reconnect_requested = pyqtSignal() # emitted when user wants to reconnect
|
|
45
|
+
title_changed = pyqtSignal(str)
|
|
46
|
+
|
|
47
|
+
def __init__(self, parent=None, multiline_threshold: int = MULTILINE_PASTE_THRESHOLD):
|
|
48
|
+
super().__init__(parent)
|
|
49
|
+
|
|
50
|
+
self._session: Optional[Session] = None
|
|
51
|
+
self._theme: Optional[Theme] = None
|
|
52
|
+
self._ready = False
|
|
53
|
+
self._pending_writes: list[bytes] = []
|
|
54
|
+
self._awaiting_reconnect_confirm = False
|
|
55
|
+
self._multiline_threshold = multiline_threshold
|
|
56
|
+
self._pending_paste: Optional[bytes] = None # held during confirmation
|
|
57
|
+
|
|
58
|
+
self._setup_ui()
|
|
59
|
+
self._setup_bridge()
|
|
60
|
+
|
|
61
|
+
def _setup_ui(self):
|
|
62
|
+
"""Set up the widget UI."""
|
|
63
|
+
layout = QVBoxLayout(self)
|
|
64
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
65
|
+
layout.setSpacing(0)
|
|
66
|
+
|
|
67
|
+
self._webview = QWebEngineView()
|
|
68
|
+
self._webview.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
|
|
69
|
+
|
|
70
|
+
# Configure web settings
|
|
71
|
+
settings = self._webview.settings()
|
|
72
|
+
settings.setAttribute(
|
|
73
|
+
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
|
|
74
|
+
True
|
|
75
|
+
)
|
|
76
|
+
settings.setAttribute(
|
|
77
|
+
QWebEngineSettings.WebAttribute.JavascriptEnabled,
|
|
78
|
+
True
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
layout.addWidget(self._webview)
|
|
82
|
+
|
|
83
|
+
def _setup_bridge(self):
|
|
84
|
+
"""Set up QWebChannel bridge to JavaScript."""
|
|
85
|
+
self._bridge = TerminalBridge()
|
|
86
|
+
self._channel = QWebChannel()
|
|
87
|
+
self._channel.registerObject("bridge", self._bridge)
|
|
88
|
+
self._webview.page().setWebChannel(self._channel)
|
|
89
|
+
|
|
90
|
+
# Connect bridge signals
|
|
91
|
+
self._bridge.data_from_terminal.connect(self._on_terminal_data)
|
|
92
|
+
self._bridge.size_changed.connect(self._on_terminal_resize)
|
|
93
|
+
self._bridge.terminal_ready.connect(self._on_terminal_ready)
|
|
94
|
+
self._bridge.title_changed.connect(self.title_changed.emit)
|
|
95
|
+
|
|
96
|
+
# Clipboard signals
|
|
97
|
+
self._bridge.selection_copied.connect(self._on_selection_copied)
|
|
98
|
+
self._bridge.paste_requested.connect(self._on_paste_requested)
|
|
99
|
+
self._bridge.paste_confirmed.connect(self._on_paste_confirmed)
|
|
100
|
+
self._bridge.paste_cancelled.connect(self._on_paste_cancelled)
|
|
101
|
+
|
|
102
|
+
# Load terminal HTML
|
|
103
|
+
try:
|
|
104
|
+
html_path = resources.get_path("terminal", "resources", "terminal.html")
|
|
105
|
+
self._webview.setUrl(QUrl.fromLocalFile(str(html_path)))
|
|
106
|
+
except FileNotFoundError as e:
|
|
107
|
+
logger.error(f"Terminal HTML not found: {e}")
|
|
108
|
+
|
|
109
|
+
def attach_session(self, session: Session) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Attach a session for I/O.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
session: Session to attach
|
|
115
|
+
"""
|
|
116
|
+
if self._session:
|
|
117
|
+
self.detach_session()
|
|
118
|
+
|
|
119
|
+
self._session = session
|
|
120
|
+
self._session.set_auto_reconnect(False) # Widget controls reconnect
|
|
121
|
+
self._session.set_event_handler(self._on_session_event)
|
|
122
|
+
self._awaiting_reconnect_confirm = False
|
|
123
|
+
logger.debug(f"Attached session")
|
|
124
|
+
|
|
125
|
+
def detach_session(self) -> None:
|
|
126
|
+
"""Detach current session."""
|
|
127
|
+
if self._session:
|
|
128
|
+
self._session.set_event_handler(None)
|
|
129
|
+
self._session = None
|
|
130
|
+
self._awaiting_reconnect_confirm = False
|
|
131
|
+
logger.debug("Detached session")
|
|
132
|
+
|
|
133
|
+
def set_theme(self, theme: Theme) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Apply theme to terminal.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
theme: Theme to apply
|
|
139
|
+
"""
|
|
140
|
+
self._theme = theme
|
|
141
|
+
if self._ready:
|
|
142
|
+
self._apply_theme()
|
|
143
|
+
|
|
144
|
+
def _apply_theme(self):
|
|
145
|
+
"""Apply current theme to terminal."""
|
|
146
|
+
if self._theme:
|
|
147
|
+
self._bridge.apply_theme.emit(json.dumps(self._theme.terminal_colors))
|
|
148
|
+
self._bridge.set_font.emit(
|
|
149
|
+
self._theme.font_family,
|
|
150
|
+
self._theme.font_size
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def write(self, data: bytes) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Write data to terminal display.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
data: Bytes to display
|
|
159
|
+
"""
|
|
160
|
+
if self._ready:
|
|
161
|
+
data_b64 = base64.b64encode(data).decode('ascii')
|
|
162
|
+
self._bridge.write_data.emit(data_b64)
|
|
163
|
+
else:
|
|
164
|
+
self._pending_writes.append(data)
|
|
165
|
+
|
|
166
|
+
def clear(self) -> None:
|
|
167
|
+
"""Clear terminal display."""
|
|
168
|
+
if self._ready:
|
|
169
|
+
self._bridge.clear_terminal.emit()
|
|
170
|
+
|
|
171
|
+
def focus(self) -> None:
|
|
172
|
+
"""Focus the terminal for input."""
|
|
173
|
+
if self._ready:
|
|
174
|
+
self._bridge.focus_terminal.emit()
|
|
175
|
+
self._webview.setFocus()
|
|
176
|
+
|
|
177
|
+
def show_overlay(self, message: str, show_spinner: bool = False) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Show overlay message.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
message: Message to display
|
|
183
|
+
show_spinner: Whether to show the spinner animation
|
|
184
|
+
"""
|
|
185
|
+
if self._ready:
|
|
186
|
+
self._bridge.show_overlay.emit(message, show_spinner)
|
|
187
|
+
|
|
188
|
+
def hide_overlay(self) -> None:
|
|
189
|
+
"""Hide overlay message."""
|
|
190
|
+
if self._ready:
|
|
191
|
+
self._bridge.hide_overlay.emit()
|
|
192
|
+
|
|
193
|
+
# -------------------------------------------------------------------------
|
|
194
|
+
# Clipboard operations
|
|
195
|
+
# -------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def copy(self) -> None:
|
|
198
|
+
"""Copy current terminal selection to clipboard."""
|
|
199
|
+
if self._ready:
|
|
200
|
+
self._bridge.do_copy.emit()
|
|
201
|
+
|
|
202
|
+
def paste(self) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Paste from clipboard with multiline safety check.
|
|
205
|
+
|
|
206
|
+
If content contains more lines than threshold, shows confirmation dialog.
|
|
207
|
+
"""
|
|
208
|
+
if not self._ready:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
clipboard = QApplication.clipboard()
|
|
212
|
+
text = clipboard.text()
|
|
213
|
+
|
|
214
|
+
if not text:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# Count newlines
|
|
218
|
+
line_count = text.count('\n')
|
|
219
|
+
|
|
220
|
+
if line_count > self._multiline_threshold:
|
|
221
|
+
# Store pending paste and request confirmation
|
|
222
|
+
self._pending_paste = text.encode('utf-8')
|
|
223
|
+
|
|
224
|
+
# Create preview (first few lines)
|
|
225
|
+
lines = text.split('\n')
|
|
226
|
+
preview_lines = lines[:5]
|
|
227
|
+
preview = '\n'.join(preview_lines)
|
|
228
|
+
if len(lines) > 5:
|
|
229
|
+
preview += f'\n... ({len(lines) - 5} more lines)'
|
|
230
|
+
|
|
231
|
+
self._bridge.show_paste_confirm.emit(preview, len(lines))
|
|
232
|
+
else:
|
|
233
|
+
# Safe to paste directly
|
|
234
|
+
self._send_paste(text.encode('utf-8'))
|
|
235
|
+
|
|
236
|
+
def copy_paste(self) -> None:
|
|
237
|
+
"""
|
|
238
|
+
Copy selection, then paste clipboard (combined operation).
|
|
239
|
+
|
|
240
|
+
Useful for workflows where you select text, then want to
|
|
241
|
+
immediately paste what was just copied.
|
|
242
|
+
"""
|
|
243
|
+
self.copy()
|
|
244
|
+
# Small delay isn't needed since copy is sync to clipboard
|
|
245
|
+
self.paste()
|
|
246
|
+
|
|
247
|
+
def _send_paste(self, data: bytes) -> None:
|
|
248
|
+
"""Actually send paste data to terminal/session."""
|
|
249
|
+
if self._session and self._session.is_connected:
|
|
250
|
+
self._session.write(data)
|
|
251
|
+
elif self._ready:
|
|
252
|
+
# Even if not connected, let terminal display it
|
|
253
|
+
# (will trigger reconnect flow via _on_terminal_data)
|
|
254
|
+
data_b64 = base64.b64encode(data).decode('ascii')
|
|
255
|
+
self._bridge.do_paste.emit(data_b64)
|
|
256
|
+
|
|
257
|
+
def set_multiline_threshold(self, lines: int) -> None:
|
|
258
|
+
"""
|
|
259
|
+
Set the line count threshold for multiline paste warnings.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
lines: Number of lines that triggers confirmation (0 to disable)
|
|
263
|
+
"""
|
|
264
|
+
self._multiline_threshold = lines
|
|
265
|
+
|
|
266
|
+
# -------------------------------------------------------------------------
|
|
267
|
+
# Bridge callbacks
|
|
268
|
+
# -------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _on_terminal_ready(self):
|
|
271
|
+
"""Terminal initialized, apply settings."""
|
|
272
|
+
logger.debug("Terminal ready")
|
|
273
|
+
self._ready = True
|
|
274
|
+
|
|
275
|
+
if self._theme:
|
|
276
|
+
self._apply_theme()
|
|
277
|
+
|
|
278
|
+
# Flush pending writes
|
|
279
|
+
for data in self._pending_writes:
|
|
280
|
+
self.write(data)
|
|
281
|
+
self._pending_writes.clear()
|
|
282
|
+
|
|
283
|
+
def _is_disconnected(self) -> bool:
|
|
284
|
+
"""Check if session is in a disconnected state."""
|
|
285
|
+
if not self._session:
|
|
286
|
+
return True
|
|
287
|
+
return self._session.state in (
|
|
288
|
+
SessionState.DISCONNECTED,
|
|
289
|
+
SessionState.FAILED,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
@pyqtSlot(str)
|
|
293
|
+
def _on_terminal_data(self, data_b64: str):
|
|
294
|
+
"""User typed in terminal."""
|
|
295
|
+
if self._session and self._session.is_connected:
|
|
296
|
+
data = base64.b64decode(data_b64)
|
|
297
|
+
self._session.write(data)
|
|
298
|
+
elif self._is_disconnected():
|
|
299
|
+
# User typed while disconnected - offer to reconnect
|
|
300
|
+
if self._awaiting_reconnect_confirm:
|
|
301
|
+
# They confirmed - reconnect
|
|
302
|
+
self._awaiting_reconnect_confirm = False
|
|
303
|
+
self.hide_overlay()
|
|
304
|
+
self.reconnect_requested.emit()
|
|
305
|
+
# Actually trigger the reconnection
|
|
306
|
+
if self._session:
|
|
307
|
+
self._session.connect()
|
|
308
|
+
else:
|
|
309
|
+
# First keypress - show prompt
|
|
310
|
+
self._awaiting_reconnect_confirm = True
|
|
311
|
+
self.show_overlay("Disconnected. Press any key to reconnect...", show_spinner=False)
|
|
312
|
+
|
|
313
|
+
@pyqtSlot(int, int)
|
|
314
|
+
def _on_terminal_resize(self, cols: int, rows: int):
|
|
315
|
+
"""Terminal resized."""
|
|
316
|
+
logger.debug(f"Terminal resize: {cols}x{rows}")
|
|
317
|
+
if self._session:
|
|
318
|
+
self._session.resize(cols, rows)
|
|
319
|
+
|
|
320
|
+
@pyqtSlot(str)
|
|
321
|
+
def _on_selection_copied(self, text: str):
|
|
322
|
+
"""Selection was copied - write to clipboard."""
|
|
323
|
+
clipboard = QApplication.clipboard()
|
|
324
|
+
clipboard.setText(text)
|
|
325
|
+
logger.debug(f"Copied {len(text)} chars to clipboard")
|
|
326
|
+
|
|
327
|
+
@pyqtSlot(str)
|
|
328
|
+
def _on_paste_requested(self, data_b64: str):
|
|
329
|
+
"""
|
|
330
|
+
JS requested paste (from context menu or keyboard).
|
|
331
|
+
Route through our paste() method which reads Qt clipboard.
|
|
332
|
+
"""
|
|
333
|
+
self.paste()
|
|
334
|
+
|
|
335
|
+
@pyqtSlot()
|
|
336
|
+
def _on_paste_confirmed(self):
|
|
337
|
+
"""User confirmed multiline paste."""
|
|
338
|
+
self._bridge.hide_paste_confirm.emit()
|
|
339
|
+
if self._pending_paste:
|
|
340
|
+
self._send_paste(self._pending_paste)
|
|
341
|
+
self._pending_paste = None
|
|
342
|
+
|
|
343
|
+
@pyqtSlot()
|
|
344
|
+
def _on_paste_cancelled(self):
|
|
345
|
+
"""User cancelled multiline paste."""
|
|
346
|
+
self._bridge.hide_paste_confirm.emit()
|
|
347
|
+
self._pending_paste = None
|
|
348
|
+
self.focus()
|
|
349
|
+
|
|
350
|
+
def _on_session_event(self, event: SessionEvent):
|
|
351
|
+
"""Handle session events."""
|
|
352
|
+
if isinstance(event, DataReceived):
|
|
353
|
+
self.write(event.data)
|
|
354
|
+
|
|
355
|
+
elif isinstance(event, StateChanged):
|
|
356
|
+
self.session_state_changed.emit(event.new_state, event.message)
|
|
357
|
+
|
|
358
|
+
# Reset reconnect confirmation state on any state change
|
|
359
|
+
self._awaiting_reconnect_confirm = False
|
|
360
|
+
|
|
361
|
+
if event.new_state == SessionState.CONNECTING:
|
|
362
|
+
self.show_overlay("Connecting...", show_spinner=True)
|
|
363
|
+
elif event.new_state == SessionState.AUTHENTICATING:
|
|
364
|
+
self.show_overlay("Authenticating...", show_spinner=True)
|
|
365
|
+
elif event.new_state == SessionState.CONNECTED:
|
|
366
|
+
self.hide_overlay()
|
|
367
|
+
elif event.new_state == SessionState.DISCONNECTED:
|
|
368
|
+
self.show_overlay("Disconnected. Press any key to reconnect...", show_spinner=False)
|
|
369
|
+
self._awaiting_reconnect_confirm = True
|
|
370
|
+
elif event.new_state == SessionState.FAILED:
|
|
371
|
+
self.show_overlay(f"Connection failed: {event.message}", show_spinner=False)
|
|
372
|
+
# Don't auto-prompt for reconnect on failure, let them decide
|
|
373
|
+
|
|
374
|
+
elif isinstance(event, InteractionRequired):
|
|
375
|
+
self.interaction_required.emit(event.prompt, event.interaction_type)
|
|
376
|
+
self.show_overlay(event.prompt, show_spinner=False)
|
|
377
|
+
|
|
378
|
+
elif isinstance(event, BannerReceived):
|
|
379
|
+
# Could display banner in overlay or write to terminal
|
|
380
|
+
logger.info(f"Banner: {event.banner[:100]}...")
|