ntermqt 0.1.3__py3-none-any.whl → 0.1.4__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/examples/basic_terminal.py +415 -0
- nterm/manager/tree.py +125 -42
- nterm/terminal/bridge.py +10 -0
- nterm/terminal/resources/terminal.html +9 -4
- nterm/terminal/resources/terminal.js +14 -1
- nterm/terminal/widget.py +73 -2
- nterm/theme/engine.py +45 -0
- nterm/theme/themes/nord_hybrid.yaml +43 -0
- nterm/vault/store.py +3 -3
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.4.dist-info}/METADATA +40 -11
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.4.dist-info}/RECORD +14 -12
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.4.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.4.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.3.dist-info → ntermqt-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Example nterm application.
|
|
4
|
+
|
|
5
|
+
Demonstrates basic usage of the terminal widget with different session types:
|
|
6
|
+
- SSHSession: Paramiko-based (for password/key auth)
|
|
7
|
+
- AskpassSSHSession: Native SSH with GUI prompts (recommended for YubiKey)
|
|
8
|
+
- InteractiveSSHSession: Native SSH with PTY
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import logging
|
|
13
|
+
from PyQt6.QtWidgets import (
|
|
14
|
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
15
|
+
QComboBox, QLabel, QPushButton, QStatusBar, QLineEdit, QSpinBox,
|
|
16
|
+
QGroupBox, QFormLayout, QMessageBox, QDialog, QDialogButtonBox,
|
|
17
|
+
QInputDialog
|
|
18
|
+
)
|
|
19
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
|
20
|
+
|
|
21
|
+
from nterm import (
|
|
22
|
+
ConnectionProfile, AuthConfig, AuthMethod, JumpHostConfig,
|
|
23
|
+
SSHSession, SessionState, TerminalWidget, Theme, ThemeEngine,
|
|
24
|
+
InteractiveSSHSession, is_pty_available
|
|
25
|
+
)
|
|
26
|
+
from nterm.session import AskpassSSHSession
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class YubiKeyDialog(QDialog):
|
|
33
|
+
"""Dialog shown when YubiKey touch is required."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, prompt: str, parent=None):
|
|
36
|
+
super().__init__(parent)
|
|
37
|
+
self.setWindowTitle("YubiKey Authentication")
|
|
38
|
+
self.setModal(True)
|
|
39
|
+
self.setMinimumWidth(350)
|
|
40
|
+
|
|
41
|
+
layout = QVBoxLayout(self)
|
|
42
|
+
|
|
43
|
+
# Icon/visual indicator
|
|
44
|
+
icon_label = QLabel("🔑")
|
|
45
|
+
icon_label.setStyleSheet("font-size: 48px;")
|
|
46
|
+
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
47
|
+
layout.addWidget(icon_label)
|
|
48
|
+
|
|
49
|
+
# Prompt
|
|
50
|
+
prompt_label = QLabel(prompt)
|
|
51
|
+
prompt_label.setWordWrap(True)
|
|
52
|
+
prompt_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
53
|
+
prompt_label.setStyleSheet("font-size: 14px; margin: 10px;")
|
|
54
|
+
layout.addWidget(prompt_label)
|
|
55
|
+
|
|
56
|
+
# Instructions
|
|
57
|
+
instructions = QLabel("Touch your YubiKey to authenticate...")
|
|
58
|
+
instructions.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
59
|
+
instructions.setStyleSheet("color: gray;")
|
|
60
|
+
layout.addWidget(instructions)
|
|
61
|
+
|
|
62
|
+
# Cancel button
|
|
63
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
|
|
64
|
+
buttons.rejected.connect(self.reject)
|
|
65
|
+
layout.addWidget(buttons)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NTermWindow(QMainWindow):
|
|
69
|
+
"""Main application window."""
|
|
70
|
+
|
|
71
|
+
def __init__(self):
|
|
72
|
+
super().__init__()
|
|
73
|
+
self.setWindowTitle("nterm - SSH Terminal")
|
|
74
|
+
self.resize(1200, 800)
|
|
75
|
+
|
|
76
|
+
self._session = None
|
|
77
|
+
self._theme_engine = ThemeEngine()
|
|
78
|
+
self._yubikey_dialog = None
|
|
79
|
+
|
|
80
|
+
self._setup_ui()
|
|
81
|
+
self._apply_theme("default")
|
|
82
|
+
|
|
83
|
+
def _setup_ui(self):
|
|
84
|
+
"""Set up the user interface."""
|
|
85
|
+
central = QWidget()
|
|
86
|
+
self.setCentralWidget(central)
|
|
87
|
+
layout = QVBoxLayout(central)
|
|
88
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
89
|
+
layout.setSpacing(0)
|
|
90
|
+
|
|
91
|
+
# Toolbar
|
|
92
|
+
toolbar = self._create_toolbar()
|
|
93
|
+
layout.addWidget(toolbar)
|
|
94
|
+
|
|
95
|
+
# Terminal
|
|
96
|
+
self._terminal = TerminalWidget()
|
|
97
|
+
self._terminal.session_state_changed.connect(self._on_state_changed)
|
|
98
|
+
self._terminal.interaction_required.connect(self._on_interaction)
|
|
99
|
+
layout.addWidget(self._terminal, 1)
|
|
100
|
+
|
|
101
|
+
# Status bar
|
|
102
|
+
self._status = QStatusBar()
|
|
103
|
+
self.setStatusBar(self._status)
|
|
104
|
+
self._status.showMessage("Disconnected")
|
|
105
|
+
|
|
106
|
+
def _create_toolbar(self) -> QWidget:
|
|
107
|
+
"""Create connection toolbar."""
|
|
108
|
+
toolbar = QWidget()
|
|
109
|
+
toolbar.setFixedHeight(100)
|
|
110
|
+
layout = QHBoxLayout(toolbar)
|
|
111
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
112
|
+
|
|
113
|
+
# Connection group
|
|
114
|
+
conn_group = QGroupBox("Connection")
|
|
115
|
+
conn_layout = QFormLayout(conn_group)
|
|
116
|
+
conn_layout.setContentsMargins(8, 4, 8, 4)
|
|
117
|
+
|
|
118
|
+
self._host_input = QLineEdit()
|
|
119
|
+
self._host_input.setPlaceholderText("hostname or IP")
|
|
120
|
+
self._host_input.setText("localhost")
|
|
121
|
+
conn_layout.addRow("Host:", self._host_input)
|
|
122
|
+
|
|
123
|
+
port_layout = QHBoxLayout()
|
|
124
|
+
self._port_input = QSpinBox()
|
|
125
|
+
self._port_input.setRange(1, 65535)
|
|
126
|
+
self._port_input.setValue(22)
|
|
127
|
+
port_layout.addWidget(self._port_input)
|
|
128
|
+
|
|
129
|
+
self._user_input = QLineEdit()
|
|
130
|
+
self._user_input.setPlaceholderText("username")
|
|
131
|
+
port_layout.addWidget(QLabel("User:"))
|
|
132
|
+
port_layout.addWidget(self._user_input)
|
|
133
|
+
conn_layout.addRow("Port:", port_layout)
|
|
134
|
+
|
|
135
|
+
layout.addWidget(conn_group)
|
|
136
|
+
|
|
137
|
+
# Session type group
|
|
138
|
+
session_group = QGroupBox("Session Type")
|
|
139
|
+
session_layout = QVBoxLayout(session_group)
|
|
140
|
+
session_layout.setContentsMargins(8, 4, 8, 4)
|
|
141
|
+
|
|
142
|
+
self._session_combo = QComboBox()
|
|
143
|
+
self._session_combo.addItem("Askpass (YubiKey GUI)", "askpass")
|
|
144
|
+
self._session_combo.addItem("Interactive (PTY)", "interactive")
|
|
145
|
+
self._session_combo.addItem("Paramiko", "paramiko")
|
|
146
|
+
self._session_combo.currentIndexChanged.connect(self._on_session_type_changed)
|
|
147
|
+
session_layout.addWidget(self._session_combo)
|
|
148
|
+
|
|
149
|
+
# Status indicator
|
|
150
|
+
self._pty_label = QLabel("✓ GUI auth prompts" if is_pty_available() else "⚠ Limited")
|
|
151
|
+
self._pty_label.setStyleSheet("color: green;" if is_pty_available() else "color: orange;")
|
|
152
|
+
session_layout.addWidget(self._pty_label)
|
|
153
|
+
|
|
154
|
+
layout.addWidget(session_group)
|
|
155
|
+
|
|
156
|
+
# Auth group (for Paramiko mode)
|
|
157
|
+
self._auth_group = QGroupBox("Authentication")
|
|
158
|
+
auth_layout = QFormLayout(self._auth_group)
|
|
159
|
+
auth_layout.setContentsMargins(8, 4, 8, 4)
|
|
160
|
+
|
|
161
|
+
self._auth_combo = QComboBox()
|
|
162
|
+
self._auth_combo.addItems(["Agent", "Password", "Key File"])
|
|
163
|
+
auth_layout.addRow("Method:", self._auth_combo)
|
|
164
|
+
|
|
165
|
+
self._password_input = QLineEdit()
|
|
166
|
+
self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
167
|
+
self._password_input.setPlaceholderText("(for password auth)")
|
|
168
|
+
auth_layout.addRow("Password:", self._password_input)
|
|
169
|
+
|
|
170
|
+
self._auth_group.setVisible(False)
|
|
171
|
+
layout.addWidget(self._auth_group)
|
|
172
|
+
|
|
173
|
+
# Jump host group
|
|
174
|
+
jump_group = QGroupBox("Jump Host (Optional)")
|
|
175
|
+
jump_layout = QFormLayout(jump_group)
|
|
176
|
+
jump_layout.setContentsMargins(8, 4, 8, 4)
|
|
177
|
+
|
|
178
|
+
self._jump_host_input = QLineEdit()
|
|
179
|
+
self._jump_host_input.setPlaceholderText("bastion.example.com")
|
|
180
|
+
jump_layout.addRow("Host:", self._jump_host_input)
|
|
181
|
+
|
|
182
|
+
self._jump_user_input = QLineEdit()
|
|
183
|
+
self._jump_user_input.setPlaceholderText("(same as main if empty)")
|
|
184
|
+
jump_layout.addRow("User:", self._jump_user_input)
|
|
185
|
+
|
|
186
|
+
layout.addWidget(jump_group)
|
|
187
|
+
|
|
188
|
+
# Theme selector
|
|
189
|
+
theme_group = QGroupBox("Theme")
|
|
190
|
+
theme_layout = QVBoxLayout(theme_group)
|
|
191
|
+
theme_layout.setContentsMargins(8, 4, 8, 4)
|
|
192
|
+
|
|
193
|
+
self._theme_combo = QComboBox()
|
|
194
|
+
self._theme_combo.addItems(self._theme_engine.list_themes())
|
|
195
|
+
self._theme_combo.currentTextChanged.connect(self._apply_theme)
|
|
196
|
+
theme_layout.addWidget(self._theme_combo)
|
|
197
|
+
|
|
198
|
+
layout.addWidget(theme_group)
|
|
199
|
+
|
|
200
|
+
# Buttons
|
|
201
|
+
btn_layout = QVBoxLayout()
|
|
202
|
+
|
|
203
|
+
self._connect_btn = QPushButton("Connect")
|
|
204
|
+
self._connect_btn.clicked.connect(self._connect)
|
|
205
|
+
self._connect_btn.setDefault(True)
|
|
206
|
+
btn_layout.addWidget(self._connect_btn)
|
|
207
|
+
|
|
208
|
+
self._disconnect_btn = QPushButton("Disconnect")
|
|
209
|
+
self._disconnect_btn.clicked.connect(self._disconnect)
|
|
210
|
+
self._disconnect_btn.setEnabled(False)
|
|
211
|
+
btn_layout.addWidget(self._disconnect_btn)
|
|
212
|
+
|
|
213
|
+
layout.addLayout(btn_layout)
|
|
214
|
+
layout.addStretch()
|
|
215
|
+
|
|
216
|
+
return toolbar
|
|
217
|
+
|
|
218
|
+
def _on_session_type_changed(self, index: int):
|
|
219
|
+
"""Handle session type change."""
|
|
220
|
+
session_type = self._session_combo.currentData()
|
|
221
|
+
self._auth_group.setVisible(session_type == "paramiko")
|
|
222
|
+
|
|
223
|
+
# Update status label
|
|
224
|
+
if session_type == "askpass":
|
|
225
|
+
self._pty_label.setText("✓ GUI auth prompts")
|
|
226
|
+
self._pty_label.setStyleSheet("color: green;")
|
|
227
|
+
elif session_type == "interactive":
|
|
228
|
+
self._pty_label.setText("⚠ Console prompts")
|
|
229
|
+
self._pty_label.setStyleSheet("color: orange;")
|
|
230
|
+
else:
|
|
231
|
+
self._pty_label.setText("✓ Programmatic auth")
|
|
232
|
+
self._pty_label.setStyleSheet("color: green;")
|
|
233
|
+
|
|
234
|
+
def _apply_theme(self, theme_name: str):
|
|
235
|
+
"""Apply selected theme."""
|
|
236
|
+
theme = self._theme_engine.get_theme(theme_name)
|
|
237
|
+
if theme:
|
|
238
|
+
self._terminal.set_theme(theme)
|
|
239
|
+
|
|
240
|
+
def _connect(self):
|
|
241
|
+
"""Establish connection."""
|
|
242
|
+
hostname = self._host_input.text().strip()
|
|
243
|
+
port = self._port_input.value()
|
|
244
|
+
username = self._user_input.text().strip()
|
|
245
|
+
session_type = self._session_combo.currentData()
|
|
246
|
+
|
|
247
|
+
if not hostname:
|
|
248
|
+
QMessageBox.warning(self, "Error", "Please enter a hostname")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
if not username:
|
|
252
|
+
QMessageBox.warning(self, "Error", "Please enter a username")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Build auth config
|
|
256
|
+
if session_type in ("askpass", "interactive"):
|
|
257
|
+
auth = AuthConfig.agent_auth(username)
|
|
258
|
+
else:
|
|
259
|
+
auth_method = self._auth_combo.currentText()
|
|
260
|
+
if auth_method == "Agent":
|
|
261
|
+
auth = AuthConfig.agent_auth(username)
|
|
262
|
+
elif auth_method == "Password":
|
|
263
|
+
password = self._password_input.text()
|
|
264
|
+
if not password:
|
|
265
|
+
QMessageBox.warning(self, "Error", "Please enter a password")
|
|
266
|
+
return
|
|
267
|
+
auth = AuthConfig.password_auth(username, password)
|
|
268
|
+
else:
|
|
269
|
+
auth = AuthConfig.agent_auth(username, allow_fallback=True)
|
|
270
|
+
|
|
271
|
+
# Build jump host config if specified
|
|
272
|
+
jump_hosts = []
|
|
273
|
+
jump_host = self._jump_host_input.text().strip()
|
|
274
|
+
if jump_host:
|
|
275
|
+
jump_user = self._jump_user_input.text().strip() or username
|
|
276
|
+
jump_hosts.append(JumpHostConfig(
|
|
277
|
+
hostname=jump_host,
|
|
278
|
+
auth=AuthConfig.agent_auth(jump_user),
|
|
279
|
+
))
|
|
280
|
+
|
|
281
|
+
# Create profile
|
|
282
|
+
profile = ConnectionProfile(
|
|
283
|
+
name=f"{username}@{hostname}",
|
|
284
|
+
hostname=hostname,
|
|
285
|
+
port=port,
|
|
286
|
+
auth_methods=[auth],
|
|
287
|
+
jump_hosts=jump_hosts,
|
|
288
|
+
auto_reconnect=False, # Disable for testing
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Create appropriate session type
|
|
292
|
+
if session_type == "askpass":
|
|
293
|
+
if not is_pty_available():
|
|
294
|
+
QMessageBox.warning(self, "Error", "PTY support required")
|
|
295
|
+
return
|
|
296
|
+
self._session = AskpassSSHSession(profile)
|
|
297
|
+
elif session_type == "interactive":
|
|
298
|
+
if not is_pty_available():
|
|
299
|
+
QMessageBox.warning(self, "Error", "PTY support required")
|
|
300
|
+
return
|
|
301
|
+
self._session = InteractiveSSHSession(profile)
|
|
302
|
+
else:
|
|
303
|
+
self._session = SSHSession(profile)
|
|
304
|
+
|
|
305
|
+
self._terminal.attach_session(self._session)
|
|
306
|
+
|
|
307
|
+
# Connect
|
|
308
|
+
self._session.connect()
|
|
309
|
+
self._connect_btn.setEnabled(False)
|
|
310
|
+
self._disconnect_btn.setEnabled(True)
|
|
311
|
+
|
|
312
|
+
def _disconnect(self):
|
|
313
|
+
"""Disconnect session."""
|
|
314
|
+
# Close any open dialogs
|
|
315
|
+
if self._yubikey_dialog:
|
|
316
|
+
self._yubikey_dialog.close()
|
|
317
|
+
self._yubikey_dialog = None
|
|
318
|
+
|
|
319
|
+
if self._session:
|
|
320
|
+
self._session.disconnect()
|
|
321
|
+
self._terminal.detach_session()
|
|
322
|
+
self._session = None
|
|
323
|
+
|
|
324
|
+
self._connect_btn.setEnabled(True)
|
|
325
|
+
self._disconnect_btn.setEnabled(False)
|
|
326
|
+
|
|
327
|
+
def _on_state_changed(self, state: SessionState, message: str):
|
|
328
|
+
"""Handle session state changes."""
|
|
329
|
+
status_text = {
|
|
330
|
+
SessionState.DISCONNECTED: "Disconnected",
|
|
331
|
+
SessionState.CONNECTING: "Connecting...",
|
|
332
|
+
SessionState.AUTHENTICATING: "Authenticating...",
|
|
333
|
+
SessionState.CONNECTED: "Connected",
|
|
334
|
+
SessionState.RECONNECTING: f"Reconnecting: {message}",
|
|
335
|
+
SessionState.FAILED: f"Failed: {message}",
|
|
336
|
+
}.get(state, str(state))
|
|
337
|
+
|
|
338
|
+
self._status.showMessage(status_text)
|
|
339
|
+
|
|
340
|
+
# Close YubiKey dialog on connect/disconnect
|
|
341
|
+
if state in (SessionState.CONNECTED, SessionState.DISCONNECTED, SessionState.FAILED):
|
|
342
|
+
if self._yubikey_dialog:
|
|
343
|
+
self._yubikey_dialog.close()
|
|
344
|
+
self._yubikey_dialog = None
|
|
345
|
+
|
|
346
|
+
if state == SessionState.CONNECTED:
|
|
347
|
+
self._connect_btn.setEnabled(False)
|
|
348
|
+
self._disconnect_btn.setEnabled(True)
|
|
349
|
+
self._terminal.focus()
|
|
350
|
+
elif state in (SessionState.DISCONNECTED, SessionState.FAILED):
|
|
351
|
+
self._connect_btn.setEnabled(True)
|
|
352
|
+
self._disconnect_btn.setEnabled(False)
|
|
353
|
+
|
|
354
|
+
def _on_interaction(self, prompt: str, interaction_type: str):
|
|
355
|
+
"""Handle SSH authentication prompts."""
|
|
356
|
+
logger.info(f"Interaction required: {interaction_type} - {prompt}")
|
|
357
|
+
|
|
358
|
+
if not isinstance(self._session, AskpassSSHSession):
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
if interaction_type == "yubikey_touch":
|
|
362
|
+
# Show YubiKey dialog
|
|
363
|
+
self._yubikey_dialog = YubiKeyDialog(prompt, self)
|
|
364
|
+
result = self._yubikey_dialog.exec()
|
|
365
|
+
self._yubikey_dialog = None
|
|
366
|
+
|
|
367
|
+
if result == QDialog.DialogCode.Rejected:
|
|
368
|
+
# User cancelled
|
|
369
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
370
|
+
else:
|
|
371
|
+
# YubiKey was touched (dialog closed by external event)
|
|
372
|
+
self._session.provide_askpass_response(True, value="")
|
|
373
|
+
|
|
374
|
+
elif interaction_type == "password":
|
|
375
|
+
# Show password dialog
|
|
376
|
+
password, ok = QInputDialog.getText(
|
|
377
|
+
self, "SSH Authentication", prompt,
|
|
378
|
+
QLineEdit.EchoMode.Password
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if ok and password:
|
|
382
|
+
self._session.provide_askpass_response(True, value=password)
|
|
383
|
+
else:
|
|
384
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
385
|
+
|
|
386
|
+
else:
|
|
387
|
+
# Generic input
|
|
388
|
+
text, ok = QInputDialog.getText(
|
|
389
|
+
self, "SSH Authentication", prompt
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if ok:
|
|
393
|
+
self._session.provide_askpass_response(True, value=text)
|
|
394
|
+
else:
|
|
395
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
396
|
+
|
|
397
|
+
def closeEvent(self, event):
|
|
398
|
+
"""Handle window close."""
|
|
399
|
+
if self._session:
|
|
400
|
+
self._session.disconnect()
|
|
401
|
+
event.accept()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def main():
|
|
405
|
+
app = QApplication(sys.argv)
|
|
406
|
+
app.setApplicationName("nterm")
|
|
407
|
+
|
|
408
|
+
window = NTermWindow()
|
|
409
|
+
window.show()
|
|
410
|
+
|
|
411
|
+
sys.exit(app.exec())
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
if __name__ == "__main__":
|
|
415
|
+
main()
|
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 =
|
|
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
|
nterm/terminal/bridge.py
CHANGED
|
@@ -38,6 +38,16 @@ class TerminalBridge(QObject):
|
|
|
38
38
|
paste_requested = pyqtSignal(str) # base64 clipboard content for confirmation
|
|
39
39
|
paste_confirmed = pyqtSignal() # user confirmed multiline paste
|
|
40
40
|
paste_cancelled = pyqtSignal() # user cancelled multiline paste
|
|
41
|
+
# Signal to JS (Python -> JavaScript)
|
|
42
|
+
set_capture_state = pyqtSignal(bool, str) # is_capturing, filename
|
|
43
|
+
|
|
44
|
+
# Signal from JS (JavaScript -> Python)
|
|
45
|
+
capture_toggled = pyqtSignal()
|
|
46
|
+
|
|
47
|
+
@pyqtSlot()
|
|
48
|
+
def onCaptureToggle(self):
|
|
49
|
+
"""Called from JS when capture menu item clicked."""
|
|
50
|
+
self.capture_toggled.emit()
|
|
41
51
|
|
|
42
52
|
def __init__(self):
|
|
43
53
|
super().__init__()
|
|
@@ -237,10 +237,15 @@
|
|
|
237
237
|
<span class="context-menu-shortcut">Ctrl+Shift+V</span>
|
|
238
238
|
</div>
|
|
239
239
|
<div class="context-menu-separator"></div>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
<div class="context-menu-item" id="ctx-capture">
|
|
241
|
+
<span id="ctx-capture-text">Start Capture...</span>
|
|
242
|
+
<span class="context-menu-shortcut"></span>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="context-menu-separator"></div>
|
|
245
|
+
<div class="context-menu-item" id="ctx-clear">
|
|
246
|
+
<span>Clear Terminal</span>
|
|
247
|
+
<span class="context-menu-shortcut"></span>
|
|
248
|
+
</div>
|
|
244
249
|
</div>
|
|
245
250
|
|
|
246
251
|
<script src="xterm.min.js"></script>
|
|
@@ -125,6 +125,12 @@
|
|
|
125
125
|
contextMenu = document.getElementById('context-menu');
|
|
126
126
|
const container = document.getElementById('terminal');
|
|
127
127
|
|
|
128
|
+
document.getElementById('ctx-capture').addEventListener('click', () => {
|
|
129
|
+
contextMenu.classList.remove('visible');
|
|
130
|
+
if (bridge) {
|
|
131
|
+
bridge.onCaptureToggle();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
128
134
|
// Show context menu on right-click
|
|
129
135
|
container.addEventListener('contextmenu', (e) => {
|
|
130
136
|
e.preventDefault();
|
|
@@ -274,7 +280,14 @@
|
|
|
274
280
|
function setupBridge() {
|
|
275
281
|
new QWebChannel(qt.webChannelTransport, function(channel) {
|
|
276
282
|
bridge = channel.objects.bridge;
|
|
277
|
-
|
|
283
|
+
bridge.set_capture_state.connect(function(isCapturing, filename) {
|
|
284
|
+
const captureText = document.getElementById('ctx-capture-text');
|
|
285
|
+
if (isCapturing) {
|
|
286
|
+
captureText.textContent = 'Stop Capture (' + filename + ')';
|
|
287
|
+
} else {
|
|
288
|
+
captureText.textContent = 'Start Capture...';
|
|
289
|
+
}
|
|
290
|
+
});
|
|
278
291
|
// Data from Python to terminal - properly decode UTF-8
|
|
279
292
|
bridge.write_data.connect(function(dataB64) {
|
|
280
293
|
try {
|
nterm/terminal/widget.py
CHANGED
|
@@ -6,11 +6,12 @@ from __future__ import annotations
|
|
|
6
6
|
import base64
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from typing import Optional
|
|
11
|
+
from typing import Optional, BinaryIO
|
|
11
12
|
|
|
12
13
|
from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot
|
|
13
|
-
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication
|
|
14
|
+
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication, QFileDialog
|
|
14
15
|
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
15
16
|
from PyQt6.QtWebEngineCore import QWebEngineSettings
|
|
16
17
|
from PyQt6.QtWebChannel import QWebChannel
|
|
@@ -29,6 +30,9 @@ from nterm.resources import resources
|
|
|
29
30
|
# Default threshold for multiline paste warning
|
|
30
31
|
MULTILINE_PASTE_THRESHOLD = 1
|
|
31
32
|
|
|
33
|
+
# ANSI escape sequence pattern for stripping from capture logs
|
|
34
|
+
ANSI_ESCAPE = re.compile(rb'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b\[[\?0-9;]*[hl]')
|
|
35
|
+
|
|
32
36
|
|
|
33
37
|
class TerminalWidget(QWidget):
|
|
34
38
|
"""
|
|
@@ -55,6 +59,10 @@ class TerminalWidget(QWidget):
|
|
|
55
59
|
self._multiline_threshold = multiline_threshold
|
|
56
60
|
self._pending_paste: Optional[bytes] = None # held during confirmation
|
|
57
61
|
|
|
62
|
+
# Session capture
|
|
63
|
+
self._capture_file: Optional[BinaryIO] = None
|
|
64
|
+
self._capture_path: Optional[Path] = None
|
|
65
|
+
|
|
58
66
|
self._setup_ui()
|
|
59
67
|
self._setup_bridge()
|
|
60
68
|
|
|
@@ -99,6 +107,9 @@ class TerminalWidget(QWidget):
|
|
|
99
107
|
self._bridge.paste_confirmed.connect(self._on_paste_confirmed)
|
|
100
108
|
self._bridge.paste_cancelled.connect(self._on_paste_cancelled)
|
|
101
109
|
|
|
110
|
+
# Capture signals
|
|
111
|
+
self._bridge.capture_toggled.connect(self._on_capture_toggle)
|
|
112
|
+
|
|
102
113
|
# Load terminal HTML
|
|
103
114
|
try:
|
|
104
115
|
html_path = resources.get_path("terminal", "resources", "terminal.html")
|
|
@@ -130,6 +141,9 @@ class TerminalWidget(QWidget):
|
|
|
130
141
|
self._awaiting_reconnect_confirm = False
|
|
131
142
|
logger.debug("Detached session")
|
|
132
143
|
|
|
144
|
+
# Stop any active capture
|
|
145
|
+
self.stop_capture()
|
|
146
|
+
|
|
133
147
|
def set_theme(self, theme: Theme) -> None:
|
|
134
148
|
"""
|
|
135
149
|
Apply theme to terminal.
|
|
@@ -157,6 +171,11 @@ class TerminalWidget(QWidget):
|
|
|
157
171
|
Args:
|
|
158
172
|
data: Bytes to display
|
|
159
173
|
"""
|
|
174
|
+
# Session capture - strip ANSI escapes for clean text
|
|
175
|
+
if self._capture_file:
|
|
176
|
+
clean = ANSI_ESCAPE.sub(b'', data)
|
|
177
|
+
self._capture_file.write(clean)
|
|
178
|
+
|
|
160
179
|
if self._ready:
|
|
161
180
|
data_b64 = base64.b64encode(data).decode('ascii')
|
|
162
181
|
self._bridge.write_data.emit(data_b64)
|
|
@@ -190,6 +209,58 @@ class TerminalWidget(QWidget):
|
|
|
190
209
|
if self._ready:
|
|
191
210
|
self._bridge.hide_overlay.emit()
|
|
192
211
|
|
|
212
|
+
# -------------------------------------------------------------------------
|
|
213
|
+
# Session capture
|
|
214
|
+
# -------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def is_capturing(self) -> bool:
|
|
218
|
+
"""Check if session capture is active."""
|
|
219
|
+
return self._capture_file is not None
|
|
220
|
+
|
|
221
|
+
def start_capture(self, path: Path) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Start capturing session output to file.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
path: File path to write captured output
|
|
227
|
+
"""
|
|
228
|
+
self.stop_capture() # Close any existing capture
|
|
229
|
+
self._capture_path = path
|
|
230
|
+
self._capture_file = open(path, 'wb')
|
|
231
|
+
self._bridge.set_capture_state.emit(True, path.name)
|
|
232
|
+
logger.info(f"Started capture: {path}")
|
|
233
|
+
|
|
234
|
+
def stop_capture(self) -> None:
|
|
235
|
+
"""Stop capturing session output."""
|
|
236
|
+
if self._capture_file:
|
|
237
|
+
self._capture_file.close()
|
|
238
|
+
logger.info(f"Stopped capture: {self._capture_path}")
|
|
239
|
+
self._capture_file = None
|
|
240
|
+
self._capture_path = None
|
|
241
|
+
self._bridge.set_capture_state.emit(False, "")
|
|
242
|
+
|
|
243
|
+
@pyqtSlot()
|
|
244
|
+
def _on_capture_toggle(self):
|
|
245
|
+
"""Handle capture menu item click."""
|
|
246
|
+
if self._capture_file:
|
|
247
|
+
self.stop_capture()
|
|
248
|
+
else:
|
|
249
|
+
# Show file save dialog
|
|
250
|
+
default_name = "session.log"
|
|
251
|
+
if self._session:
|
|
252
|
+
# Use hostname if available for default filename
|
|
253
|
+
default_name = f"session_{self._session.hostname}.log" if hasattr(self._session, 'hostname') else "session.log"
|
|
254
|
+
|
|
255
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
256
|
+
self,
|
|
257
|
+
"Save Session Capture",
|
|
258
|
+
str(Path.home() / default_name),
|
|
259
|
+
"Log Files (*.log *.txt);;All Files (*)"
|
|
260
|
+
)
|
|
261
|
+
if path:
|
|
262
|
+
self.start_capture(Path(path))
|
|
263
|
+
|
|
193
264
|
# -------------------------------------------------------------------------
|
|
194
265
|
# Clipboard operations
|
|
195
266
|
# -------------------------------------------------------------------------
|
nterm/theme/engine.py
CHANGED
|
@@ -564,6 +564,50 @@ class Theme:
|
|
|
564
564
|
overlay_text_color="#3c3836",
|
|
565
565
|
)
|
|
566
566
|
|
|
567
|
+
@classmethod
|
|
568
|
+
def nord_hybrid(cls) -> Theme:
|
|
569
|
+
"""
|
|
570
|
+
Nord Hybrid theme.
|
|
571
|
+
|
|
572
|
+
Polar Night UI + Snow Storm terminal.
|
|
573
|
+
Dark chrome, soft light terminal (not harsh white).
|
|
574
|
+
"""
|
|
575
|
+
return cls(
|
|
576
|
+
name="nord_hybrid",
|
|
577
|
+
terminal_colors={
|
|
578
|
+
"background": "#eceff4",
|
|
579
|
+
"foreground": "#2e3440",
|
|
580
|
+
"cursor": "#2e3440",
|
|
581
|
+
"cursorAccent": "#eceff4",
|
|
582
|
+
"selectionBackground": "#d8dee9",
|
|
583
|
+
"selectionForeground": "#2e3440",
|
|
584
|
+
"black": "#2e3440",
|
|
585
|
+
"red": "#bf616a",
|
|
586
|
+
"green": "#a3be8c",
|
|
587
|
+
"yellow": "#d08770",
|
|
588
|
+
"blue": "#5e81ac",
|
|
589
|
+
"magenta": "#b48ead",
|
|
590
|
+
"cyan": "#88c0d0",
|
|
591
|
+
"white": "#d8dee9",
|
|
592
|
+
"brightBlack": "#4c566a",
|
|
593
|
+
"brightRed": "#bf616a",
|
|
594
|
+
"brightGreen": "#a3be8c",
|
|
595
|
+
"brightYellow": "#ebcb8b",
|
|
596
|
+
"brightBlue": "#81a1c1",
|
|
597
|
+
"brightMagenta": "#b48ead",
|
|
598
|
+
"brightCyan": "#8fbcbb",
|
|
599
|
+
"brightWhite": "#eceff4",
|
|
600
|
+
},
|
|
601
|
+
font_family="JetBrains Mono, Cascadia Code, Consolas, Menlo, monospace",
|
|
602
|
+
font_size=14,
|
|
603
|
+
background_color="#2e3440",
|
|
604
|
+
foreground_color="#d8dee9",
|
|
605
|
+
border_color="#3b4252",
|
|
606
|
+
accent_color="#88c0d0",
|
|
607
|
+
overlay_background="rgba(46, 52, 64, 0.95)",
|
|
608
|
+
overlay_text_color="#eceff4",
|
|
609
|
+
)
|
|
610
|
+
|
|
567
611
|
@classmethod
|
|
568
612
|
def gruvbox_hybrid(cls) -> Theme:
|
|
569
613
|
"""
|
|
@@ -630,6 +674,7 @@ class ThemeEngine:
|
|
|
630
674
|
self._themes["default"] = Theme.default()
|
|
631
675
|
self._themes["dracula"] = Theme.dracula()
|
|
632
676
|
self._themes["nord"] = Theme.nord()
|
|
677
|
+
self._themes["nord_hybrid"] = Theme.nord_hybrid()
|
|
633
678
|
self._themes["solarized_dark"] = Theme.solarized_dark()
|
|
634
679
|
self._themes["gruvbox_dark"] = Theme.gruvbox_dark()
|
|
635
680
|
self._themes["gruvbox_light"] = Theme.gruvbox_light()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: nord_hybrid
|
|
2
|
+
|
|
3
|
+
# Nord Hybrid theme
|
|
4
|
+
# Polar Night UI + softer Snow Storm terminal
|
|
5
|
+
# Adjusted palette for better contrast on light background
|
|
6
|
+
|
|
7
|
+
terminal_colors:
|
|
8
|
+
# Softer terminal - nord4 instead of nord6
|
|
9
|
+
background: "#d8dee9"
|
|
10
|
+
foreground: "#2e3440"
|
|
11
|
+
cursor: "#2e3440"
|
|
12
|
+
cursorAccent: "#d8dee9"
|
|
13
|
+
selectionBackground: "#4c566a"
|
|
14
|
+
selectionForeground: "#eceff4"
|
|
15
|
+
# Darkened palette for light background contrast
|
|
16
|
+
black: "#2e3440"
|
|
17
|
+
red: "#a54242"
|
|
18
|
+
green: "#4e6a3d"
|
|
19
|
+
yellow: "#a07040"
|
|
20
|
+
blue: "#3b6186"
|
|
21
|
+
magenta: "#8a4b7c"
|
|
22
|
+
cyan: "#2b7694"
|
|
23
|
+
white: "#4c566a"
|
|
24
|
+
brightBlack: "#3b4252"
|
|
25
|
+
brightRed: "#bf616a"
|
|
26
|
+
brightGreen: "#5c8045"
|
|
27
|
+
brightYellow: "#d08770"
|
|
28
|
+
brightBlue: "#5e81ac"
|
|
29
|
+
brightMagenta: "#b48ead"
|
|
30
|
+
brightCyan: "#4e9a9a"
|
|
31
|
+
brightWhite: "#2e3440"
|
|
32
|
+
|
|
33
|
+
font_family: "JetBrains Mono, Cascadia Code, Consolas, monospace"
|
|
34
|
+
font_size: 14
|
|
35
|
+
|
|
36
|
+
# Polar Night UI chrome (unchanged)
|
|
37
|
+
background_color: "#2e3440"
|
|
38
|
+
foreground_color: "#d8dee9"
|
|
39
|
+
border_color: "#3b4252"
|
|
40
|
+
accent_color: "#88c0d0"
|
|
41
|
+
|
|
42
|
+
overlay_background: "rgba(46, 52, 64, 0.95)"
|
|
43
|
+
overlay_text_color: "#eceff4"
|
nterm/vault/store.py
CHANGED
|
@@ -295,7 +295,7 @@ class CredentialStore:
|
|
|
295
295
|
|
|
296
296
|
# Encrypt sensitive fields
|
|
297
297
|
password_enc = self._encrypt(password) if password else None
|
|
298
|
-
ssh_key_enc = self._encrypt(ssh_key) if ssh_key else None
|
|
298
|
+
ssh_key_enc = self._encrypt(ssh_key.strip()) if ssh_key else None
|
|
299
299
|
ssh_key_pass_enc = self._encrypt(ssh_key_passphrase) if ssh_key_passphrase else None
|
|
300
300
|
|
|
301
301
|
# Serialize lists
|
|
@@ -523,8 +523,8 @@ class CredentialStore:
|
|
|
523
523
|
updates['password_enc'] = self._encrypt(kwargs['password']) if kwargs['password'] else None
|
|
524
524
|
|
|
525
525
|
if 'ssh_key' in kwargs:
|
|
526
|
-
updates['ssh_key_enc'] = self._encrypt(kwargs['ssh_key']) if kwargs['ssh_key'] else None
|
|
527
|
-
|
|
526
|
+
updates['ssh_key_enc'] = self._encrypt(kwargs['ssh_key'].strip()) if kwargs['ssh_key'] else None
|
|
527
|
+
|
|
528
528
|
if 'ssh_key_passphrase' in kwargs:
|
|
529
529
|
updates['ssh_key_passphrase_enc'] = self._encrypt(kwargs['ssh_key_passphrase']) if kwargs['ssh_key_passphrase'] else None
|
|
530
530
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntermqt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
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
|
|
@@ -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**
|
|
@@ -306,19 +308,33 @@ session.connect()
|
|
|
306
308
|
|
|
307
309
|
## Themes
|
|
308
310
|
|
|
309
|
-
|
|
311
|
+
nterm includes 12 built-in themes covering dark, light, and hybrid styles.
|
|
312
|
+
|
|
313
|
+
### Built-in Themes
|
|
310
314
|
|
|
311
315
|
```python
|
|
312
|
-
|
|
313
|
-
Theme.
|
|
314
|
-
Theme.
|
|
315
|
-
Theme.
|
|
316
|
-
Theme.
|
|
317
|
-
Theme.
|
|
318
|
-
Theme.
|
|
316
|
+
# Dark themes
|
|
317
|
+
Theme.default() # Catppuccin Mocha
|
|
318
|
+
Theme.dracula() # Dracula
|
|
319
|
+
Theme.nord() # Nord
|
|
320
|
+
Theme.solarized_dark() # Solarized Dark
|
|
321
|
+
Theme.gruvbox_dark() # Gruvbox Dark
|
|
322
|
+
Theme.enterprise_dark() # Microsoft-inspired dark
|
|
323
|
+
|
|
324
|
+
# Light themes
|
|
325
|
+
Theme.gruvbox_light() # Gruvbox Light
|
|
326
|
+
Theme.enterprise_light() # Microsoft-inspired light
|
|
327
|
+
Theme.clean() # Warm paper tones
|
|
328
|
+
|
|
329
|
+
# Hybrid themes (dark UI + light terminal)
|
|
330
|
+
Theme.gruvbox_hybrid() # Gruvbox dark chrome, light terminal
|
|
331
|
+
Theme.nord_hybrid() # Nord polar night chrome, snow storm terminal
|
|
332
|
+
Theme.enterprise_hybrid() # VS Code-style dark/light split
|
|
319
333
|
```
|
|
320
334
|
|
|
321
|
-
|
|
335
|
+
**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.
|
|
336
|
+
|
|
337
|
+
### Custom YAML Themes
|
|
322
338
|
|
|
323
339
|
```yaml
|
|
324
340
|
# ~/.nterm/themes/my-theme.yaml
|
|
@@ -347,6 +363,19 @@ accent_color: "#7aa2f7"
|
|
|
347
363
|
|
|
348
364
|
---
|
|
349
365
|
|
|
366
|
+
## Session Capture
|
|
367
|
+
|
|
368
|
+
Capture session output to a file for documentation, auditing, or extracting config snippets.
|
|
369
|
+
|
|
370
|
+
**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.
|
|
371
|
+
|
|
372
|
+
- Per-session capture (each tab independent)
|
|
373
|
+
- File dialog for save location
|
|
374
|
+
- Menu shows active capture filename
|
|
375
|
+
- Auto-stops when session closes
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
350
379
|
## Jump Hosts
|
|
351
380
|
|
|
352
381
|
```python
|
|
@@ -6,13 +6,14 @@ nterm/askpass/__init__.py,sha256=UpJBk0EOm0nkRwMVv7YdIB4v75ZJpSYmNsU_GlgzbUg,495
|
|
|
6
6
|
nterm/askpass/server.py,sha256=5tvjYryyfu-n8Cw2KbucwaZfWiqYnFk-iBAVBI8FMfw,12873
|
|
7
7
|
nterm/connection/__init__.py,sha256=2qQ9LGxUxmwem8deOD2WZVkeD6rIVlTlx5Zh2cUEmxY,261
|
|
8
8
|
nterm/connection/profile.py,sha256=4RMgnRNKCc-dFGEIpmQc_bob5MtzxO04_PljP-qUGLs,9450
|
|
9
|
+
nterm/examples/basic_terminal.py,sha256=vbDI1xl-Radv6GYZ0yC6QUafQp_tSX2pWIf7tk58W8E,15256
|
|
9
10
|
nterm/manager/__init__.py,sha256=_QIeTap5CTL3jdTS1Q16fAt-PrqcNPUVr9gtJ22f0ng,774
|
|
10
11
|
nterm/manager/connect_dialog.py,sha256=yd8g_gYttT_UdflRxSfyss8OQTfrvKLUOMg4Kj8FPNo,11711
|
|
11
12
|
nterm/manager/editor.py,sha256=Fn2YWHJ1EwPYrhKhsi4GTBYwRfCYsHsqgKkLY-LQ8JI,8469
|
|
12
13
|
nterm/manager/io.py,sha256=R5ksWgpEz0VdVCokcgTN5G3PFgp5QYhjjt40OypSWkY,21687
|
|
13
14
|
nterm/manager/models.py,sha256=cvC2HzCRadNG1EYsnZN4C9YS6uolHGcUGGZtt-wzGF4,12237
|
|
14
15
|
nterm/manager/settings.py,sha256=r6MTw_9r1Wl2UX_ALpXIuPbDvJ0D91Y8wRKq6Bfr_3g,9210
|
|
15
|
-
nterm/manager/tree.py,sha256=
|
|
16
|
+
nterm/manager/tree.py,sha256=I78wSjkSuyM6903II-XNyPug9saMSODUNBCHCDrq4ls,22397
|
|
16
17
|
nterm/scripting/__init__.py,sha256=4WvwvJfJNMwXW6jas8wFreIzKBgjvAhMQnR2cnA_mEE,967
|
|
17
18
|
nterm/scripting/api.py,sha256=O-EyV0ksj7LATMSSPrDJShE3x4JPuEBs0SsPZdc2yUo,13931
|
|
18
19
|
nterm/scripting/cli.py,sha256=W2DK4ZnuutaArye_to7CBchg0ogClURxVbGsMdnj1y0,9187
|
|
@@ -24,17 +25,17 @@ nterm/session/local_terminal.py,sha256=sG2lFAOpItMiT93dYCi05nrGRS-MB52XG4J-iZbco
|
|
|
24
25
|
nterm/session/pty_transport.py,sha256=QwSFqKKuJhgcLWzv1CUKf3aCGDGbbkmmGwIB1L1A2PU,17176
|
|
25
26
|
nterm/session/ssh.py,sha256=sGOxjBa9FX6GjVwkmfiKsupoLVsrPVk-LSREjlNmAdE,20942
|
|
26
27
|
nterm/terminal/__init__.py,sha256=uFnG366Z166pK-ijT1dZanVSSFVZCiMGeNKXvss_sDg,184
|
|
27
|
-
nterm/terminal/bridge.py,sha256=
|
|
28
|
-
nterm/terminal/widget.py,sha256=
|
|
29
|
-
nterm/terminal/resources/terminal.html,sha256=
|
|
30
|
-
nterm/terminal/resources/terminal.js,sha256=
|
|
28
|
+
nterm/terminal/bridge.py,sha256=mSkxZr3UGyaFI14w08dzekCkOhfUetq0GIjrBtA3qI0,3199
|
|
29
|
+
nterm/terminal/widget.py,sha256=mxUrQxFmigNR6S3vgnzHahTRGYQI2bNYTBqNg47yaR8,15716
|
|
30
|
+
nterm/terminal/resources/terminal.html,sha256=1onb3qUdDa0qzETR8XaKx0UR6BPlCm_ZpMFVgt36ZPA,7985
|
|
31
|
+
nterm/terminal/resources/terminal.js,sha256=zW9n1MRujSXv66ENgU-gzk_mc75EpWye_f88ejChSW4,13852
|
|
31
32
|
nterm/terminal/resources/xterm-addon-fit.min.js,sha256=x45XlcZIes3ySrQ2eY1KnOw4SBAbKBvGWwYfOdtxS-E,1789
|
|
32
33
|
nterm/terminal/resources/xterm-addon-unicode11.min.js,sha256=_sT7CbMSksBfUPmKZYj29IDjq7LMjiwciFs0iGNomBM,7500
|
|
33
34
|
nterm/terminal/resources/xterm-addon-web-links.min.js,sha256=_iizzOZ3_DRg6y7iu111muLnWVW8bzC9V6_EAPu0hK8,3219
|
|
34
35
|
nterm/terminal/resources/xterm.css,sha256=gy8_LGA7Q61DUf8ElwFQzHqHMBQnbbEmpgZcbdgeSHI,5383
|
|
35
36
|
nterm/terminal/resources/xterm.min.js,sha256=_B3TGyIePl-SlIbgeoC0d6iq-dzitPnD_-fdJfNwZV0,283670
|
|
36
37
|
nterm/theme/__init__.py,sha256=ZTywoJliQcFre0Gh7I30n-_7RrPmdR1NHnE4wSkSCsQ,130
|
|
37
|
-
nterm/theme/engine.py,sha256=
|
|
38
|
+
nterm/theme/engine.py,sha256=0C3K9hoFOdEVJv3xJXmPs1DPGT2mSVJNtA0dDm4w-uA,26340
|
|
38
39
|
nterm/theme/stylesheet.py,sha256=Ycy-y_kiP-SLcQFrAEdJtbSDtKm4yvBfxEe-N26qlDg,9004
|
|
39
40
|
nterm/theme/themes/clean.yaml,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
40
41
|
nterm/theme/themes/default.yaml,sha256=niUrI_K8fayPCZDy1gc3hueLtkWjfmm1p1R33JjYgS4,810
|
|
@@ -45,15 +46,16 @@ nterm/theme/themes/enterprise_light.yaml,sha256=Q6H5lSsStoFVJNFS63IPp0FaBhkjN9uB
|
|
|
45
46
|
nterm/theme/themes/gruvbox_dark.yaml,sha256=cAr-67R7QhW80ncHptpyyrZuUqD65xoSuLtmHeDgQM0,815
|
|
46
47
|
nterm/theme/themes/gruvbox_hybrid.yaml,sha256=Ml7Ed3sTBjcSYVJ9t961KhiG3DwMAdVdRBtzI4eWZg0,936
|
|
47
48
|
nterm/theme/themes/gruvbox_light.yaml,sha256=InqYF-TsLLIzhCHpSSHqSxnest5tu28htQ4AaFN4BFY,820
|
|
49
|
+
nterm/theme/themes/nord_hybrid.yaml,sha256=QAT056Jo2UAdQPmbc3GezjpD7Mge-GQSl4wPeSiaqSE,1065
|
|
48
50
|
nterm/vault/__init__.py,sha256=e1W3GZKOf0FXNerSp1mojl-yaidYIsygnRwTGBd6mfM,708
|
|
49
51
|
nterm/vault/credential_manager.py,sha256=TWAMfjpntPXEJ-4AauDz2PPS0q140sUebFk8AjvC-A0,5347
|
|
50
52
|
nterm/vault/keychain.py,sha256=_2-yUhc2ro-An2zvFlJHYyxozM55iJ4bSseOVKMCNGo,4229
|
|
51
53
|
nterm/vault/manager_ui.py,sha256=qle-W40j6L_pOR0AaOCeyU8myizFTRkISNrloCn0H_Y,34530
|
|
52
54
|
nterm/vault/profile.py,sha256=qM9TJf68RKdjtxo-sJehO7wS4iTi2G26BKbmlmHLA5M,6246
|
|
53
55
|
nterm/vault/resolver.py,sha256=GWB2YR9H1MH98RGQBKvitIsjWT_-wSMLuddZNz4wbns,7800
|
|
54
|
-
nterm/vault/store.py,sha256=
|
|
55
|
-
ntermqt-0.1.
|
|
56
|
-
ntermqt-0.1.
|
|
57
|
-
ntermqt-0.1.
|
|
58
|
-
ntermqt-0.1.
|
|
59
|
-
ntermqt-0.1.
|
|
56
|
+
nterm/vault/store.py,sha256=_0Lfe0WKjm3uSAtxgn9qAPlpBOLCuq9SVgzqsE_qaGQ,21199
|
|
57
|
+
ntermqt-0.1.4.dist-info/METADATA,sha256=riL6P_O6COkgWjf4eWq-7cEFiFiorlhlNCvTdr4xhvg,13573
|
|
58
|
+
ntermqt-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
59
|
+
ntermqt-0.1.4.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
|
|
60
|
+
ntermqt-0.1.4.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
|
|
61
|
+
ntermqt-0.1.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|