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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/manager/io.py ADDED
@@ -0,0 +1,678 @@
1
+ """
2
+ Session import/export functionality.
3
+
4
+ Supports JSON format for portability.
5
+ Also supports importing from TerminalTelemetry YAML format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import json
10
+ import yaml
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ from typing import Optional
14
+ from dataclasses import asdict
15
+
16
+ from PyQt6.QtWidgets import (
17
+ QWidget, QFileDialog, QMessageBox, QDialog,
18
+ QVBoxLayout, QHBoxLayout, QLabel, QCheckBox,
19
+ QPushButton, QDialogButtonBox, QTreeWidget,
20
+ QTreeWidgetItem, QGroupBox
21
+ )
22
+ from PyQt6.QtCore import Qt
23
+
24
+ from .models import SessionStore, SavedSession, SessionFolder
25
+
26
+
27
+ # Export format version for future compatibility
28
+ EXPORT_VERSION = 1
29
+
30
+
31
+ def export_sessions(
32
+ store: SessionStore,
33
+ path: Path,
34
+ include_stats: bool = False
35
+ ) -> int:
36
+ """
37
+ Export all sessions to JSON file.
38
+
39
+ Args:
40
+ store: Session store instance
41
+ path: Output file path
42
+ include_stats: Include connect_count and last_connected
43
+
44
+ Returns:
45
+ Number of sessions exported
46
+ """
47
+ tree_data = store.get_tree()
48
+
49
+ # Build export structure
50
+ export_data = {
51
+ "version": EXPORT_VERSION,
52
+ "exported_at": datetime.now().isoformat(),
53
+ "folders": [],
54
+ "sessions": [],
55
+ }
56
+
57
+ # Export folders
58
+ for folder in tree_data["folders"]:
59
+ export_data["folders"].append({
60
+ "id": folder.id,
61
+ "name": folder.name,
62
+ "parent_id": folder.parent_id,
63
+ "position": folder.position,
64
+ })
65
+
66
+ # Export sessions
67
+ for session in tree_data["sessions"]:
68
+ session_data = {
69
+ "name": session.name,
70
+ "description": session.description,
71
+ "hostname": session.hostname,
72
+ "port": session.port,
73
+ "credential_name": session.credential_name,
74
+ "folder_id": session.folder_id,
75
+ "position": session.position,
76
+ }
77
+
78
+ if session.extras:
79
+ session_data["extras"] = session.extras
80
+
81
+ if include_stats:
82
+ session_data["connect_count"] = session.connect_count
83
+ if session.last_connected:
84
+ session_data["last_connected"] = str(session.last_connected)
85
+
86
+ export_data["sessions"].append(session_data)
87
+
88
+ # Write file
89
+ with open(path, "w") as f:
90
+ json.dump(export_data, f, indent=2)
91
+
92
+ return len(export_data["sessions"])
93
+
94
+
95
+ def import_sessions(
96
+ store: SessionStore,
97
+ path: Path,
98
+ merge: bool = True
99
+ ) -> tuple[int, int]:
100
+ """
101
+ Import sessions from JSON file.
102
+
103
+ Args:
104
+ store: Session store instance
105
+ path: Input file path
106
+ merge: If True, merge with existing. If False, skip duplicates.
107
+
108
+ Returns:
109
+ Tuple of (sessions_imported, sessions_skipped)
110
+ """
111
+ with open(path) as f:
112
+ data = json.load(f)
113
+
114
+ version = data.get("version", 1)
115
+
116
+ # Build folder ID mapping (old ID -> new ID)
117
+ folder_map: dict[int, int] = {}
118
+
119
+ # Import folders first
120
+ if "folders" in data:
121
+ # Sort by parent to ensure parents are created first
122
+ folders = sorted(data["folders"], key=lambda f: (f.get("parent_id") or 0, f.get("position", 0)))
123
+
124
+ for folder_data in folders:
125
+ old_id = folder_data.get("id")
126
+ parent_id = folder_data.get("parent_id")
127
+
128
+ # Map parent ID if it was imported
129
+ if parent_id and parent_id in folder_map:
130
+ parent_id = folder_map[parent_id]
131
+ elif parent_id:
132
+ parent_id = None # Parent not found, put at root
133
+
134
+ # Check if folder with same name exists at same level
135
+ existing = store.list_folders(parent_id)
136
+ existing_folder = next(
137
+ (f for f in existing if f.name == folder_data["name"]),
138
+ None
139
+ )
140
+
141
+ if existing_folder:
142
+ folder_map[old_id] = existing_folder.id
143
+ else:
144
+ new_id = store.add_folder(folder_data["name"], parent_id)
145
+ folder_map[old_id] = new_id
146
+
147
+ # Import sessions
148
+ imported = 0
149
+ skipped = 0
150
+
151
+ existing_sessions = {s.hostname: s for s in store.list_all_sessions()}
152
+
153
+ for session_data in data.get("sessions", []):
154
+ hostname = session_data.get("hostname")
155
+ name = session_data.get("name")
156
+
157
+ # Check for duplicate by hostname
158
+ if hostname in existing_sessions and not merge:
159
+ skipped += 1
160
+ continue
161
+
162
+ # Map folder ID
163
+ folder_id = session_data.get("folder_id")
164
+ if folder_id and folder_id in folder_map:
165
+ folder_id = folder_map[folder_id]
166
+ else:
167
+ folder_id = None
168
+
169
+ session = SavedSession(
170
+ name=name or hostname,
171
+ description=session_data.get("description", ""),
172
+ hostname=hostname,
173
+ port=session_data.get("port", 22),
174
+ credential_name=session_data.get("credential_name"),
175
+ folder_id=folder_id,
176
+ extras=session_data.get("extras", {}),
177
+ )
178
+
179
+ # Check if we're updating existing
180
+ if hostname in existing_sessions and merge:
181
+ existing = existing_sessions[hostname]
182
+ session.id = existing.id
183
+ store.update_session(session)
184
+ else:
185
+ store.add_session(session)
186
+
187
+ imported += 1
188
+
189
+ return imported, skipped
190
+
191
+
192
+ def import_terminal_telemetry(
193
+ store: SessionStore,
194
+ path: Path,
195
+ merge: bool = True
196
+ ) -> tuple[int, int, int]:
197
+ """
198
+ Import sessions from TerminalTelemetry YAML format.
199
+
200
+ Args:
201
+ store: Session store instance
202
+ path: Path to TerminalTelemetry sessions.yaml
203
+ merge: If True, merge with existing. If False, skip duplicates.
204
+
205
+ Returns:
206
+ Tuple of (folders_created, sessions_imported, sessions_skipped)
207
+ """
208
+ with open(path) as f:
209
+ data = yaml.safe_load(f)
210
+
211
+ if not isinstance(data, list):
212
+ raise ValueError("Invalid TerminalTelemetry format: expected list of folders")
213
+
214
+ folders_created = 0
215
+ sessions_imported = 0
216
+ sessions_skipped = 0
217
+
218
+ existing_sessions = {s.hostname: s for s in store.list_all_sessions()}
219
+
220
+ for folder_entry in data:
221
+ folder_name = folder_entry.get("folder_name", "Imported")
222
+ sessions = folder_entry.get("sessions", [])
223
+
224
+ if not sessions:
225
+ continue # Skip empty folders
226
+
227
+ # Find or create folder
228
+ existing_folders = store.list_folders(None) # Root level
229
+ folder = next((f for f in existing_folders if f.name == folder_name), None)
230
+
231
+ if not folder:
232
+ folder_id = store.add_folder(folder_name)
233
+ folders_created += 1
234
+ else:
235
+ folder_id = folder.id
236
+
237
+ # Import sessions in this folder
238
+ for sess in sessions:
239
+ hostname = sess.get("host", "")
240
+ if not hostname:
241
+ continue
242
+
243
+ # Check for duplicate
244
+ if hostname in existing_sessions and not merge:
245
+ sessions_skipped += 1
246
+ continue
247
+
248
+ # Build description from DeviceType and Model
249
+ device_type = sess.get("DeviceType", "")
250
+ model = sess.get("Model", "")
251
+ vendor = sess.get("Vendor", "")
252
+
253
+ desc_parts = []
254
+ if device_type:
255
+ desc_parts.append(device_type)
256
+ if model:
257
+ desc_parts.append(model)
258
+ description = " - ".join(desc_parts) if desc_parts else ""
259
+
260
+ # Store extra metadata
261
+ extras = {}
262
+ if vendor:
263
+ extras["vendor"] = vendor
264
+ if device_type:
265
+ extras["device_type"] = device_type
266
+ if model:
267
+ extras["model"] = model
268
+
269
+ session = SavedSession(
270
+ name=sess.get("display_name", hostname),
271
+ description=description,
272
+ hostname=hostname,
273
+ port=int(sess.get("port", 22)),
274
+ credential_name=None, # Use agent auth by default
275
+ folder_id=folder_id,
276
+ extras=extras,
277
+ )
278
+
279
+ # Check if updating existing
280
+ if hostname in existing_sessions and merge:
281
+ existing = existing_sessions[hostname]
282
+ session.id = existing.id
283
+ store.update_session(session)
284
+ else:
285
+ store.add_session(session)
286
+ existing_sessions[hostname] = session # Track for duplicates
287
+
288
+ sessions_imported += 1
289
+
290
+ return folders_created, sessions_imported, sessions_skipped
291
+
292
+
293
+ class ExportDialog(QDialog):
294
+ """Dialog for export options."""
295
+
296
+ def __init__(self, store: SessionStore, parent: QWidget = None):
297
+ super().__init__(parent)
298
+ self.store = store
299
+
300
+ self.setWindowTitle("Export Sessions")
301
+ self.setMinimumWidth(400)
302
+
303
+ layout = QVBoxLayout(self)
304
+
305
+ # Info
306
+ tree_data = store.get_tree()
307
+ count = len(tree_data["sessions"])
308
+ folder_count = len(tree_data["folders"])
309
+
310
+ info = QLabel(f"Export {count} sessions and {folder_count} folders to JSON file.")
311
+ layout.addWidget(info)
312
+
313
+ # Options
314
+ options_group = QGroupBox("Options")
315
+ options_layout = QVBoxLayout(options_group)
316
+
317
+ self._include_stats = QCheckBox("Include connection statistics")
318
+ self._include_stats.setToolTip("Export connect count and last connected timestamp")
319
+ options_layout.addWidget(self._include_stats)
320
+
321
+ layout.addWidget(options_group)
322
+
323
+ # Buttons
324
+ buttons = QDialogButtonBox(
325
+ QDialogButtonBox.StandardButton.Save |
326
+ QDialogButtonBox.StandardButton.Cancel
327
+ )
328
+ buttons.accepted.connect(self._on_save)
329
+ buttons.rejected.connect(self.reject)
330
+ layout.addWidget(buttons)
331
+
332
+ def _on_save(self) -> None:
333
+ """Handle save button."""
334
+ path, _ = QFileDialog.getSaveFileName(
335
+ self,
336
+ "Export Sessions",
337
+ "nterm_sessions.json",
338
+ "JSON Files (*.json)"
339
+ )
340
+
341
+ if path:
342
+ try:
343
+ count = export_sessions(
344
+ self.store,
345
+ Path(path),
346
+ include_stats=self._include_stats.isChecked()
347
+ )
348
+ QMessageBox.information(
349
+ self,
350
+ "Export Complete",
351
+ f"Exported {count} sessions to:\n{path}"
352
+ )
353
+ self.accept()
354
+ except Exception as e:
355
+ QMessageBox.critical(
356
+ self,
357
+ "Export Failed",
358
+ f"Failed to export sessions:\n{e}"
359
+ )
360
+
361
+
362
+ class ImportDialog(QDialog):
363
+ """Dialog for import options and preview."""
364
+
365
+ def __init__(self, store: SessionStore, parent: QWidget = None):
366
+ super().__init__(parent)
367
+ self.store = store
368
+ self._import_path: Optional[Path] = None
369
+ self._import_data: Optional[dict] = None
370
+
371
+ self.setWindowTitle("Import Sessions")
372
+ self.setMinimumWidth(500)
373
+ self.setMinimumHeight(400)
374
+
375
+ layout = QVBoxLayout(self)
376
+
377
+ # File selection
378
+ file_row = QHBoxLayout()
379
+ self._file_label = QLabel("No file selected")
380
+ file_row.addWidget(self._file_label, 1)
381
+
382
+ browse_btn = QPushButton("Browse...")
383
+ browse_btn.clicked.connect(self._browse_file)
384
+ file_row.addWidget(browse_btn)
385
+
386
+ layout.addLayout(file_row)
387
+
388
+ # Preview tree
389
+ preview_group = QGroupBox("Preview")
390
+ preview_layout = QVBoxLayout(preview_group)
391
+
392
+ self._preview_tree = QTreeWidget()
393
+ self._preview_tree.setHeaderLabels(["Name", "Host", "Port"])
394
+ self._preview_tree.setRootIsDecorated(True)
395
+ preview_layout.addWidget(self._preview_tree)
396
+
397
+ layout.addWidget(preview_group)
398
+
399
+ # Options
400
+ options_group = QGroupBox("Import Options")
401
+ options_layout = QVBoxLayout(options_group)
402
+
403
+ self._merge_check = QCheckBox("Merge with existing (update duplicates)")
404
+ self._merge_check.setChecked(True)
405
+ self._merge_check.setToolTip(
406
+ "If checked, sessions with matching hostnames will be updated.\n"
407
+ "If unchecked, duplicates will be skipped."
408
+ )
409
+ options_layout.addWidget(self._merge_check)
410
+
411
+ layout.addWidget(options_group)
412
+
413
+ # Buttons
414
+ self._button_box = QDialogButtonBox(
415
+ QDialogButtonBox.StandardButton.Ok |
416
+ QDialogButtonBox.StandardButton.Cancel
417
+ )
418
+ self._button_box.accepted.connect(self._on_import)
419
+ self._button_box.rejected.connect(self.reject)
420
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
421
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Import")
422
+ layout.addWidget(self._button_box)
423
+
424
+ def _browse_file(self) -> None:
425
+ """Browse for import file."""
426
+ path, _ = QFileDialog.getOpenFileName(
427
+ self,
428
+ "Import Sessions",
429
+ "",
430
+ "JSON Files (*.json);;All Files (*)"
431
+ )
432
+
433
+ if path:
434
+ self._load_preview(Path(path))
435
+
436
+ def _load_preview(self, path: Path) -> None:
437
+ """Load and preview import file."""
438
+ try:
439
+ with open(path) as f:
440
+ data = json.load(f)
441
+
442
+ self._import_path = path
443
+ self._import_data = data
444
+ self._file_label.setText(path.name)
445
+
446
+ # Build preview tree
447
+ self._preview_tree.clear()
448
+
449
+ # Create folder items
450
+ folder_items: dict[int, QTreeWidgetItem] = {}
451
+ for folder_data in data.get("folders", []):
452
+ item = QTreeWidgetItem()
453
+ item.setText(0, f"📁 {folder_data['name']}")
454
+ folder_items[folder_data["id"]] = item
455
+
456
+ # Parent folders
457
+ for folder_data in data.get("folders", []):
458
+ item = folder_items[folder_data["id"]]
459
+ parent_id = folder_data.get("parent_id")
460
+ if parent_id and parent_id in folder_items:
461
+ folder_items[parent_id].addChild(item)
462
+ else:
463
+ self._preview_tree.addTopLevelItem(item)
464
+
465
+ # Add sessions
466
+ for session_data in data.get("sessions", []):
467
+ item = QTreeWidgetItem()
468
+ item.setText(0, session_data.get("name", ""))
469
+ item.setText(1, session_data.get("hostname", ""))
470
+ item.setText(2, str(session_data.get("port", 22)))
471
+
472
+ folder_id = session_data.get("folder_id")
473
+ if folder_id and folder_id in folder_items:
474
+ folder_items[folder_id].addChild(item)
475
+ else:
476
+ self._preview_tree.addTopLevelItem(item)
477
+
478
+ self._preview_tree.expandAll()
479
+ self._preview_tree.resizeColumnToContents(0)
480
+
481
+ # Enable import button
482
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True)
483
+
484
+ except Exception as e:
485
+ QMessageBox.critical(
486
+ self,
487
+ "Load Failed",
488
+ f"Failed to load file:\n{e}"
489
+ )
490
+
491
+ def _on_import(self) -> None:
492
+ """Perform import."""
493
+ if not self._import_path:
494
+ return
495
+
496
+ try:
497
+ imported, skipped = import_sessions(
498
+ self.store,
499
+ self._import_path,
500
+ merge=self._merge_check.isChecked()
501
+ )
502
+
503
+ msg = f"Imported {imported} sessions."
504
+ if skipped:
505
+ msg += f"\nSkipped {skipped} duplicates."
506
+
507
+ QMessageBox.information(self, "Import Complete", msg)
508
+ self.accept()
509
+
510
+ except Exception as e:
511
+ QMessageBox.critical(
512
+ self,
513
+ "Import Failed",
514
+ f"Failed to import sessions:\n{e}"
515
+ )
516
+
517
+
518
+ class ImportTerminalTelemetryDialog(QDialog):
519
+ """Dialog for importing TerminalTelemetry sessions.yaml."""
520
+
521
+ def __init__(self, store: SessionStore, parent: QWidget = None):
522
+ super().__init__(parent)
523
+ self.store = store
524
+ self._import_path: Optional[Path] = None
525
+ self._import_data: Optional[list] = None
526
+
527
+ self.setWindowTitle("Import from TerminalTelemetry")
528
+ self.setMinimumWidth(500)
529
+ self.setMinimumHeight(400)
530
+
531
+ layout = QVBoxLayout(self)
532
+
533
+ # Info
534
+ info = QLabel(
535
+ "Import sessions from TerminalTelemetry sessions.yaml file.\n"
536
+ "Folders and sessions will be created automatically."
537
+ )
538
+ layout.addWidget(info)
539
+
540
+ # File selection
541
+ file_row = QHBoxLayout()
542
+ self._file_label = QLabel("No file selected")
543
+ file_row.addWidget(self._file_label, 1)
544
+
545
+ browse_btn = QPushButton("Browse...")
546
+ browse_btn.clicked.connect(self._browse_file)
547
+ file_row.addWidget(browse_btn)
548
+
549
+ layout.addLayout(file_row)
550
+
551
+ # Preview tree
552
+ preview_group = QGroupBox("Preview")
553
+ preview_layout = QVBoxLayout(preview_group)
554
+
555
+ self._preview_tree = QTreeWidget()
556
+ self._preview_tree.setHeaderLabels(["Name", "Host", "Description"])
557
+ self._preview_tree.setRootIsDecorated(True)
558
+ preview_layout.addWidget(self._preview_tree)
559
+
560
+ layout.addWidget(preview_group)
561
+
562
+ # Options
563
+ options_group = QGroupBox("Import Options")
564
+ options_layout = QVBoxLayout(options_group)
565
+
566
+ self._merge_check = QCheckBox("Merge with existing (update duplicates)")
567
+ self._merge_check.setChecked(True)
568
+ options_layout.addWidget(self._merge_check)
569
+
570
+ layout.addWidget(options_group)
571
+
572
+ # Buttons
573
+ self._button_box = QDialogButtonBox(
574
+ QDialogButtonBox.StandardButton.Ok |
575
+ QDialogButtonBox.StandardButton.Cancel
576
+ )
577
+ self._button_box.accepted.connect(self._on_import)
578
+ self._button_box.rejected.connect(self.reject)
579
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
580
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Import")
581
+ layout.addWidget(self._button_box)
582
+
583
+ def _browse_file(self) -> None:
584
+ """Browse for sessions.yaml file."""
585
+ # Default to common TerminalTelemetry location
586
+ default_path = Path.home() / ".terminaltelemetry" / "sessions.yaml"
587
+ start_dir = str(default_path.parent) if default_path.parent.exists() else ""
588
+
589
+ path, _ = QFileDialog.getOpenFileName(
590
+ self,
591
+ "Select TerminalTelemetry sessions.yaml",
592
+ start_dir,
593
+ "YAML Files (*.yaml *.yml);;All Files (*)"
594
+ )
595
+
596
+ if path:
597
+ self._load_preview(Path(path))
598
+
599
+ def _load_preview(self, path: Path) -> None:
600
+ """Load and preview the YAML file."""
601
+ try:
602
+ with open(path) as f:
603
+ data = yaml.safe_load(f)
604
+
605
+ if not isinstance(data, list):
606
+ raise ValueError("Invalid format: expected list of folders")
607
+
608
+ self._import_path = path
609
+ self._import_data = data
610
+ self._file_label.setText(path.name)
611
+
612
+ # Build preview tree
613
+ self._preview_tree.clear()
614
+
615
+ for folder_entry in data:
616
+ folder_name = folder_entry.get("folder_name", "Unknown")
617
+ sessions = folder_entry.get("sessions", [])
618
+
619
+ # Create folder item
620
+ folder_item = QTreeWidgetItem()
621
+ folder_item.setText(0, f"📁 {folder_name}")
622
+ folder_item.setText(1, "")
623
+ folder_item.setText(2, f"{len(sessions)} sessions")
624
+ self._preview_tree.addTopLevelItem(folder_item)
625
+
626
+ # Add sessions
627
+ for sess in sessions:
628
+ item = QTreeWidgetItem()
629
+ item.setText(0, sess.get("display_name", ""))
630
+ item.setText(1, sess.get("host", ""))
631
+
632
+ # Build description preview
633
+ device_type = sess.get("DeviceType", "")
634
+ model = sess.get("Model", "")
635
+ desc = f"{device_type} - {model}" if model else device_type
636
+ item.setText(2, desc)
637
+
638
+ folder_item.addChild(item)
639
+
640
+ self._preview_tree.expandAll()
641
+ for i in range(3):
642
+ self._preview_tree.resizeColumnToContents(i)
643
+
644
+ # Enable import button
645
+ self._button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(True)
646
+
647
+ except Exception as e:
648
+ QMessageBox.critical(
649
+ self,
650
+ "Load Failed",
651
+ f"Failed to load file:\n{e}"
652
+ )
653
+
654
+ def _on_import(self) -> None:
655
+ """Perform import."""
656
+ if not self._import_path:
657
+ return
658
+
659
+ try:
660
+ folders, imported, skipped = import_terminal_telemetry(
661
+ self.store,
662
+ self._import_path,
663
+ merge=self._merge_check.isChecked()
664
+ )
665
+
666
+ msg = f"Created {folders} folders.\nImported {imported} sessions."
667
+ if skipped:
668
+ msg += f"\nSkipped {skipped} duplicates."
669
+
670
+ QMessageBox.information(self, "Import Complete", msg)
671
+ self.accept()
672
+
673
+ except Exception as e:
674
+ QMessageBox.critical(
675
+ self,
676
+ "Import Failed",
677
+ f"Failed to import sessions:\n{e}"
678
+ )