adbsshdeck 0.1.1__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.
@@ -0,0 +1,1192 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import tempfile
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Callable, List, Optional
8
+
9
+ from ...session import ConnectionKind, SessionProfile, ssh_command_args
10
+ from ...services.adb_devices import friendly_name_for_serial
11
+ from ..session_login_dialog import SessionLoginDialog, SessionLoginOutcome
12
+
13
+ from PyQt5.QtCore import QSize, Qt, QProcess, QTimer
14
+ from PyQt5.QtGui import QFont, QKeySequence, QTextCursor
15
+ from PyQt5.QtWidgets import (
16
+ QAbstractItemView,
17
+ QApplication,
18
+ QDialog,
19
+ QFileDialog,
20
+ QHBoxLayout,
21
+ QLabel,
22
+ QLineEdit,
23
+ QListWidget,
24
+ QListWidgetItem,
25
+ QMenu,
26
+ QMessageBox,
27
+ QPlainTextEdit,
28
+ QPushButton,
29
+ QSplitter,
30
+ QStyle,
31
+ QTabWidget,
32
+ QVBoxLayout,
33
+ QWidget,
34
+ )
35
+
36
+ from ...config import AppConfig
37
+ from ..combo_utils import ExpandAllComboBox
38
+ from ..icon_utils import bookmark_icon_from_entry, icon_windows_cmd_console, icon_windows_powershell
39
+
40
+
41
+ def _serial_from_combo_text(text: str) -> str:
42
+ t = (text or "").strip()
43
+ if not t or t.startswith("No ") or "not found" in t.lower():
44
+ return ""
45
+ return t.split()[0]
46
+
47
+
48
+ def adb_interactive_shell_command(adb_path: str, serial: Optional[str] = None) -> List[str]:
49
+ """adb shell with -t -t: stdin is not a TTY (QProcess), so one -t is not enough; double -t forces remote PTY."""
50
+ cmd = [adb_path]
51
+ s = (serial or "").strip()
52
+ if s:
53
+ cmd.extend(["-s", s])
54
+ # Matches native `adb shell`: prompts like hostname:/path $ (see adb: "Use multiple -t options...")
55
+ cmd.extend(["shell", "-t", "-t"])
56
+ return cmd
57
+
58
+
59
+ def _adb_terminal_banner(adb_path: str, serial: str, display_label: str) -> str:
60
+ """One line at top of buffer so device identity is visible even before the shell prints a prompt."""
61
+ s = (serial or "").strip()
62
+ name = (display_label or "").strip()
63
+ if not name and s:
64
+ name = friendly_name_for_serial(adb_path, s)
65
+ if not s:
66
+ return f"ADB shell · {name or 'default device'}"
67
+ # Avoid "… · RFCY… · RFCY…" when the tab label is already the serial / model lookup matches serial.
68
+ if not name or name == s:
69
+ return f"ADB shell · {s}"
70
+ return f"ADB shell · {name} · {s}"
71
+
72
+
73
+ def _normalize_pty_text(data: str, *, collapse_blank_runs: bool = True) -> str:
74
+ """CRLF / stray CR from PTYs; strip ANSI. Optional blank-run collapse for 3+ newlines."""
75
+ if not data:
76
+ return data
77
+ # Strip ANSI escape/control sequences so `clear` and terminal color codes do not print raw bytes.
78
+ data = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", data)
79
+ data = re.sub(r"\x1b\][^\x07]*(\x07|\x1b\\)", "", data)
80
+ data = data.replace("\r\n", "\n")
81
+ data = data.replace("\r", "\n")
82
+ if collapse_blank_runs:
83
+ # Collapse 3+ newlines only (do not merge \n\n).
84
+ data = re.sub(r"\n{3,}", "\n\n", data)
85
+ return data
86
+
87
+
88
+ class ShellPlainTextEdit(QPlainTextEdit):
89
+ """Shell output + typing at the end. History (Up/Down), context menu, Ctrl+Shift+C/V."""
90
+
91
+ def __init__(self, parent=None):
92
+ super().__init__(parent)
93
+ self._anchor = 0
94
+ self._on_commit_line: Optional[Callable[[str], None]] = None
95
+ self._on_save_buffer: Optional[Callable[[], None]] = None
96
+ self._cmd_history: List[str] = []
97
+ self._hist_browse_idx: Optional[int] = None
98
+ self._hist_stash: str = ""
99
+ self.setCursorWidth(2)
100
+ self.setUndoRedoEnabled(False)
101
+
102
+ def set_on_commit(self, fn: Callable[[str], None]) -> None:
103
+ self._on_commit_line = fn
104
+
105
+ def set_on_save_buffer(self, fn: Callable[[], None]) -> None:
106
+ self._on_save_buffer = fn
107
+
108
+ def set_initial_content(self, text: str) -> None:
109
+ self.setPlainText(text)
110
+ cur = self.textCursor()
111
+ cur.movePosition(QTextCursor.End)
112
+ self.setTextCursor(cur)
113
+ self._anchor = cur.position()
114
+ self._reset_history_browse()
115
+
116
+ def append_from_process(self, text: str) -> None:
117
+ self.moveCursor(QTextCursor.End)
118
+ self.insertPlainText(text)
119
+ self._anchor = self.textCursor().position()
120
+ self._reset_history_browse()
121
+ self.ensureCursorVisible()
122
+
123
+ def _reset_history_browse(self) -> None:
124
+ self._hist_browse_idx = None
125
+ self._hist_stash = ""
126
+
127
+ def _replace_input_tail(self, text: str) -> None:
128
+ cur = self.textCursor()
129
+ cur.setPosition(self._anchor)
130
+ cur.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
131
+ cur.removeSelectedText()
132
+ cur.insertText(text)
133
+ cur.movePosition(QTextCursor.End)
134
+ self.setTextCursor(cur)
135
+
136
+ def _history_prev(self) -> None:
137
+ if not self._cmd_history:
138
+ return
139
+ doc = self.toPlainText()
140
+ tail = doc[self._anchor :]
141
+ if self._hist_browse_idx is None:
142
+ self._hist_stash = tail
143
+ self._hist_browse_idx = len(self._cmd_history) - 1
144
+ else:
145
+ self._hist_browse_idx = max(0, self._hist_browse_idx - 1)
146
+ self._replace_input_tail(self._cmd_history[self._hist_browse_idx])
147
+
148
+ def _history_next(self) -> None:
149
+ if self._hist_browse_idx is None:
150
+ return
151
+ if self._hist_browse_idx >= len(self._cmd_history) - 1:
152
+ self._replace_input_tail(self._hist_stash)
153
+ self._reset_history_browse()
154
+ return
155
+ self._hist_browse_idx += 1
156
+ self._replace_input_tail(self._cmd_history[self._hist_browse_idx])
157
+
158
+ def contextMenuEvent(self, event):
159
+ menu = self.createStandardContextMenu(event.pos())
160
+ menu.addSeparator()
161
+ a_copy_all = menu.addAction("Copy all")
162
+ a_copy_all.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
163
+ a_copy_all.triggered.connect(self._copy_all_to_clipboard)
164
+ if self._on_save_buffer is not None:
165
+ a_save = menu.addAction("Save terminal output as…")
166
+ a_save.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
167
+ a_save.triggered.connect(self._on_save_buffer)
168
+ a_clear = menu.addAction("Clear terminal output")
169
+ a_clear.setIcon(self.style().standardIcon(QStyle.SP_LineEditClearButton))
170
+ a_clear.triggered.connect(self._clear_buffer)
171
+ menu.addSeparator()
172
+ pa = menu.addAction("Paste at prompt")
173
+ pa.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
174
+ pa.setShortcut(QKeySequence("Ctrl+Shift+V"))
175
+ pa.triggered.connect(self._paste_at_prompt)
176
+ menu.exec_(event.globalPos())
177
+
178
+ def _copy_all_to_clipboard(self) -> None:
179
+ QApplication.clipboard().setText(self.toPlainText())
180
+
181
+ def _paste_at_prompt(self) -> None:
182
+ self.moveCursor(QTextCursor.End)
183
+ self.paste()
184
+
185
+ def _clear_buffer(self) -> None:
186
+ self.clear()
187
+ self._anchor = 0
188
+ self._reset_history_browse()
189
+ self.moveCursor(QTextCursor.End)
190
+
191
+ def keyPressEvent(self, ev):
192
+ cur = self.textCursor()
193
+ pos = cur.position()
194
+ k = ev.key()
195
+ mods = ev.modifiers()
196
+
197
+ if mods == (Qt.ControlModifier | Qt.ShiftModifier):
198
+ if k == Qt.Key_C:
199
+ self.copy()
200
+ ev.accept()
201
+ return
202
+ if k == Qt.Key_V:
203
+ self.moveCursor(QTextCursor.End)
204
+ self.paste()
205
+ ev.accept()
206
+ return
207
+
208
+ if mods & Qt.ControlModifier:
209
+ if k == Qt.Key_C:
210
+ self.copy()
211
+ ev.accept()
212
+ return
213
+ if k == Qt.Key_V:
214
+ self.moveCursor(QTextCursor.End)
215
+ self.paste()
216
+ ev.accept()
217
+ return
218
+ if k == Qt.Key_X:
219
+ self.copy()
220
+ ev.accept()
221
+ return
222
+
223
+ if cur.hasSelection():
224
+ start = min(cur.selectionStart(), cur.selectionEnd())
225
+ if start < self._anchor and ev.text():
226
+ self.moveCursor(QTextCursor.End)
227
+ cur = self.textCursor()
228
+ pos = cur.position()
229
+
230
+ if pos >= self._anchor and self._hist_browse_idx is not None and ev.text() and k not in (
231
+ Qt.Key_Up,
232
+ Qt.Key_Down,
233
+ ):
234
+ self._reset_history_browse()
235
+
236
+ if k in (Qt.Key_Up, Qt.Key_Down) and pos >= self._anchor and not (mods & Qt.ControlModifier) and not (
237
+ mods & Qt.AltModifier
238
+ ):
239
+ if k == Qt.Key_Up:
240
+ self._history_prev()
241
+ else:
242
+ self._history_next()
243
+ ev.accept()
244
+ return
245
+
246
+ if mods & Qt.ControlModifier:
247
+ if k in (Qt.Key_C, Qt.Key_A):
248
+ return super().keyPressEvent(ev)
249
+ if k == Qt.Key_V and pos < self._anchor:
250
+ self.moveCursor(QTextCursor.End)
251
+ return super().keyPressEvent(ev)
252
+
253
+ if k in (Qt.Key_Return, Qt.Key_Enter):
254
+ if mods & Qt.ShiftModifier:
255
+ return super().keyPressEvent(ev)
256
+ self._commit_current_line()
257
+ ev.accept()
258
+ return
259
+
260
+ if k == Qt.Key_Backspace and pos <= self._anchor:
261
+ ev.accept()
262
+ return
263
+ if k == Qt.Key_Delete and pos < self._anchor:
264
+ ev.accept()
265
+ return
266
+
267
+ if k == Qt.Key_Left and pos <= self._anchor:
268
+ ev.accept()
269
+ return
270
+
271
+ if pos < self._anchor and ev.text():
272
+ self.moveCursor(QTextCursor.End)
273
+
274
+ super().keyPressEvent(ev)
275
+
276
+ def mousePressEvent(self, ev):
277
+ super().mousePressEvent(ev)
278
+ if ev.button() == Qt.LeftButton and self.textCursor().position() < self._anchor:
279
+ cur = self.textCursor()
280
+ cur.movePosition(QTextCursor.End)
281
+ self.setTextCursor(cur)
282
+
283
+ def _commit_current_line(self) -> None:
284
+ cur = self.textCursor()
285
+ pos = cur.position()
286
+ doc = self.toPlainText()
287
+ line = doc[self._anchor : pos]
288
+ if "\n" in line:
289
+ line = line.split("\n", 1)[0]
290
+ end_input = self._anchor + len(line)
291
+ if line.strip():
292
+ if not self._cmd_history or self._cmd_history[-1] != line:
293
+ self._cmd_history.append(line)
294
+ self._reset_history_browse()
295
+ # Remove what we typed before sending: an interactive shell echoes the line from the PTY, so
296
+ # keeping it here duplicates "ls" / blank lines and can interleave with async stdout if we send first.
297
+ cur = self.textCursor()
298
+ cur.setPosition(self._anchor)
299
+ cur.setPosition(end_input, QTextCursor.KeepAnchor)
300
+ cur.removeSelectedText()
301
+ doc = self.toPlainText()
302
+ pos = self.textCursor().position()
303
+ # If the shell did not end the previous line with a newline, insert one so the next prompt/output
304
+ # does not glue to the prior line. Do NOT insert when typing on the same line as a prompt:
305
+ # Windows `>`, Android/adb ` $` / `#`, or trailing space after those.
306
+ if pos > 0:
307
+ prev = doc[pos - 1]
308
+ at_prompt = prev in " \t" or prev in ">$#"
309
+ if prev != "\n" and not at_prompt:
310
+ cur.insertText("\n")
311
+ pos += 1
312
+ self._anchor = pos
313
+ self.setTextCursor(cur)
314
+ if self._on_commit_line:
315
+ self._on_commit_line(line)
316
+
317
+
318
+ class SessionWidget(QWidget):
319
+ """Single terminal session: scrollback + type-at-end shell (Moba-like dark theme)."""
320
+
321
+ def __init__(
322
+ self,
323
+ session_label: str,
324
+ command: List[str],
325
+ banner: Optional[str] = None,
326
+ *,
327
+ working_dir: Optional[str] = None,
328
+ shell_profile: Optional[str] = None,
329
+ ):
330
+ super().__init__()
331
+ self.session_label = session_label
332
+ self.command = command
333
+ self._banner = (banner or "").strip() or None
334
+ self._trim_first_pty_chunk = bool(self._banner)
335
+ self._shell_profile = shell_profile if shell_profile in ("cmd", "powershell") else None
336
+ try:
337
+ base = Path(working_dir or os.getcwd()).resolve()
338
+ except OSError:
339
+ base = Path.cwd()
340
+ self._working_dir_str = str(base)
341
+ self._cwd: Path = base
342
+ self.proc = QProcess(self)
343
+ self._log_fp = None
344
+ self._log_path = self._build_log_path()
345
+ self._build_ui()
346
+ self._start()
347
+
348
+ @property
349
+ def _is_adb_shell(self) -> bool:
350
+ if not self.command:
351
+ return False
352
+ low = [str(x).lower() for x in self.command]
353
+ return "shell" in low and "adb" in str(self.command[0]).lower()
354
+
355
+ def _build_log_path(self) -> Path:
356
+ safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in self.session_label)[:48] or "session"
357
+ d = Path(tempfile.gettempdir()) / "devicedeck_terminal_logs"
358
+ d.mkdir(parents=True, exist_ok=True)
359
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
360
+ return d / f"{ts}_{safe}.log"
361
+
362
+ def _build_ui(self):
363
+ layout = QVBoxLayout(self)
364
+ layout.setContentsMargins(2, 2, 2, 2)
365
+ layout.setSpacing(0)
366
+
367
+ self.output = ShellPlainTextEdit()
368
+ self.output.setObjectName("MobaTerminalOutput")
369
+ self.output.setFont(QFont("Consolas", 11))
370
+ self.output.setLineWrapMode(QPlainTextEdit.NoWrap)
371
+ # Keep UI responsive; full stream is persisted to disk.
372
+ self.output.setMaximumBlockCount(60000)
373
+ self.output.set_on_commit(self._send_line)
374
+ self.output.setContextMenuPolicy(Qt.DefaultContextMenu)
375
+ self.output.set_on_save_buffer(self._save_buffer_as)
376
+ layout.addWidget(self.output, 1)
377
+ self._session_footer = QLabel()
378
+ self._session_footer.setObjectName("TerminalSessionFooter")
379
+ self._session_footer.setFont(QFont("Consolas", 10))
380
+ self._session_footer.setWordWrap(True)
381
+ self._session_footer.setTextInteractionFlags(Qt.TextSelectableByMouse)
382
+ self._session_footer.setToolTip(
383
+ "Current folder and session type. Type in the pane above: the caret should sit one space "
384
+ "after the > at the end of the last line (same as Windows Terminal)."
385
+ )
386
+ layout.addWidget(self._session_footer)
387
+ self._update_session_footer()
388
+ self._pending_chunks: List[str] = []
389
+ self._flush_timer = QTimer(self)
390
+ self._flush_timer.setSingleShot(True)
391
+ self._flush_timer.setInterval(25)
392
+ self._flush_timer.timeout.connect(self._flush_pending_output)
393
+
394
+ self.proc.readyReadStandardOutput.connect(self._read_stdout)
395
+ self.proc.readyReadStandardError.connect(self._read_stderr)
396
+ self.proc.finished.connect(self._on_proc_finished)
397
+
398
+ def shutdown(self, *, wait_for_process: bool = False) -> None:
399
+ """Stop the shell process when the tab is closed.
400
+
401
+ On normal tab close, the kill is fire-and-forget. On application exit, pass
402
+ ``wait_for_process=True`` so ADB/SSH child processes are reaped before teardown.
403
+ """
404
+ if self.proc.state() == QProcess.Running:
405
+ self.proc.kill()
406
+ if wait_for_process:
407
+ self.proc.waitForFinished(8000)
408
+ self._close_log()
409
+
410
+ def _on_proc_finished(self):
411
+ self.output.append_from_process("\n[session terminated]\n")
412
+ self._write_log("\n[session terminated]\n")
413
+ self._write_log(f"[full log path] {self._log_path}\n")
414
+ if hasattr(self, "_session_footer"):
415
+ self._session_footer.setText(f"Session · {self.session_label} — ended")
416
+ self._close_log()
417
+
418
+ def _update_session_footer(self) -> None:
419
+ if not hasattr(self, "_session_footer"):
420
+ return
421
+ if self._shell_profile == "cmd":
422
+ self._session_footer.setText(f"Session · {self.session_label} · {self._cwd}> ")
423
+ elif self._shell_profile == "powershell":
424
+ self._session_footer.setText(f"Session · {self.session_label} · PS {self._cwd}> ")
425
+ else:
426
+ self._session_footer.setText(f"Session · {self.session_label}")
427
+
428
+ def _ensure_prompt_trailing_space(self) -> None:
429
+ """CMD only: pipe-backed cmd may end with `>` without a space. PowerShell prints its own prompt; adding a space here caused extra gaps and missing prompt lines."""
430
+ if self._shell_profile != "cmd":
431
+ return
432
+ doc = self.output.toPlainText()
433
+ if not doc or doc.endswith("> ") or not doc.rstrip().endswith(">"):
434
+ return
435
+ last = self._last_nonempty_line().rstrip()
436
+ if not re.search(r"[A-Za-z]:\\[^>\n]*>$", last):
437
+ return
438
+ self.output.append_from_process(" ")
439
+
440
+ def _send_line(self, line: str) -> None:
441
+ if self.proc.state() != QProcess.Running:
442
+ return
443
+ self._apply_local_cd_line(line)
444
+ self._update_session_footer()
445
+ self._write_log(f">>> {line}\n")
446
+ self.proc.write((line + "\n").encode("utf-8", errors="replace"))
447
+
448
+ def _format_local_prompt(self) -> str:
449
+ """Trailing space after `>` so the caret sits one space past the prompt (CMD pipe sessions only)."""
450
+ return f"{str(self._cwd)}> "
451
+
452
+ def _last_nonempty_line(self) -> str:
453
+ for line in reversed(self.output.toPlainText().split("\n")):
454
+ if line.strip():
455
+ return line
456
+ return ""
457
+
458
+ def _buffer_already_shows_prompt(self) -> bool:
459
+ if self._shell_profile != "cmd":
460
+ return True
461
+ last = self._last_nonempty_line().rstrip()
462
+ if not last:
463
+ return False
464
+ return bool(re.search(r"[A-Za-z]:\\[^>\n]*>\s*$", last))
465
+
466
+ def _maybe_append_synthetic_prompt(self) -> None:
467
+ if not self._shell_profile or self.proc.state() != QProcess.Running:
468
+ return
469
+ # PowerShell already prints "PS path>" on stdout; a synthetic line duplicates it (PS ...> PS ...>).
470
+ if self._shell_profile == "powershell":
471
+ return
472
+ if self._buffer_already_shows_prompt():
473
+ return
474
+ p = self._format_local_prompt()
475
+ if not self.output.toPlainText().endswith("\n"):
476
+ self.output.append_from_process("\n")
477
+ self.output.append_from_process(p)
478
+
479
+ def _apply_local_cd_line(self, line: str) -> None:
480
+ """Best-effort cwd for the synthetic prompt (the real shell updates its own cwd)."""
481
+ if not self._shell_profile:
482
+ return
483
+ raw = (line or "").strip()
484
+ if not raw:
485
+ return
486
+ low = raw.lower()
487
+ arg_part: Optional[str] = None
488
+ if low.startswith("cd "):
489
+ arg_part = raw[3:].strip()
490
+ elif low.startswith("cd\\") or low.startswith("cd/"):
491
+ arg_part = raw[2:].lstrip("/\\").strip()
492
+ elif self._shell_profile == "powershell":
493
+ if low.startswith("set-location "):
494
+ arg_part = raw.split(None, 1)[1].strip() if len(raw.split()) > 1 else ""
495
+ elif low == "sl" or low.startswith("sl "):
496
+ arg_part = raw.split(None, 1)[1].strip() if len(raw.split()) > 1 else ""
497
+ if arg_part is None:
498
+ return
499
+ arg = arg_part.strip().strip('"').strip("'")
500
+ if not arg:
501
+ return
502
+ if self._shell_profile == "cmd" and arg.lower().startswith("/d "):
503
+ rest = arg[3:].strip()
504
+ try:
505
+ self._cwd = Path(rest).resolve()
506
+ except OSError:
507
+ pass
508
+ return
509
+ if arg == "..":
510
+ self._cwd = self._cwd.parent
511
+ return
512
+ if arg in (".\\", "./", "."):
513
+ return
514
+ p = Path(arg)
515
+ if not p.is_absolute():
516
+ try:
517
+ self._cwd = (self._cwd / arg).resolve()
518
+ except OSError:
519
+ pass
520
+ else:
521
+ try:
522
+ self._cwd = p.resolve()
523
+ except OSError:
524
+ pass
525
+
526
+ def send_line(self, line: str) -> None:
527
+ """Send a full line to the shell (same as pressing Enter after typing)."""
528
+ self._send_line((line or "").rstrip("\n"))
529
+
530
+ def _tighten_adb_chunk(self, text: str) -> str:
531
+ """ADB shell PTY often emits extra blank lines between prompt, echo, and output — collapse to a single newline."""
532
+ if not text:
533
+ return text
534
+ text = re.sub(r"\n{2,}", "\n", text)
535
+ doc = self.output.toPlainText()
536
+ if doc.endswith("\n") and text.startswith("\n"):
537
+ text = text[1:]
538
+ return text
539
+
540
+ def _append(self, data: str):
541
+ if not data:
542
+ return
543
+ data = _normalize_pty_text(data)
544
+ if self._trim_first_pty_chunk:
545
+ data = data.lstrip("\n")
546
+ if not data:
547
+ return
548
+ self._trim_first_pty_chunk = False
549
+ if not data:
550
+ return
551
+ self._write_log(data)
552
+ self._pending_chunks.append(data)
553
+ if not self._flush_timer.isActive():
554
+ self._flush_timer.start()
555
+
556
+ def _flush_pending_output(self) -> None:
557
+ if not self._pending_chunks:
558
+ return
559
+ text = "".join(self._pending_chunks)
560
+ self._pending_chunks.clear()
561
+ if self._is_adb_shell:
562
+ text = self._tighten_adb_chunk(text)
563
+ self.output.append_from_process(text)
564
+ self._maybe_append_synthetic_prompt()
565
+ self._ensure_prompt_trailing_space()
566
+ self._update_session_footer()
567
+
568
+ def _start(self):
569
+ # Banner line only; first PTY bytes may include leading newlines — trimmed in _append.
570
+ self.output.set_initial_content((self._banner + "\n") if self._banner else "")
571
+ self._open_log()
572
+ if self._banner:
573
+ self._write_log(self._banner + "\n")
574
+ self.proc.setWorkingDirectory(self._working_dir_str)
575
+ self.proc.start(self.command[0], self.command[1:])
576
+ if not self.proc.waitForStarted(3500):
577
+ self._append("Failed to start terminal process.\n")
578
+ self.output.setFocus(Qt.OtherFocusReason)
579
+ if self._shell_profile == "cmd":
580
+ QTimer.singleShot(120, self._maybe_append_synthetic_prompt)
581
+ self._update_session_footer()
582
+
583
+ def _read_stdout(self):
584
+ self._append(bytes(self.proc.readAllStandardOutput()).decode(errors="ignore"))
585
+
586
+ def _read_stderr(self):
587
+ self._append(bytes(self.proc.readAllStandardError()).decode(errors="ignore"))
588
+
589
+ def _save_buffer_as(self) -> None:
590
+ suggested = f"{self.session_label.replace(' ', '_')}.txt"
591
+ path, _ = QFileDialog.getSaveFileName(self, "Save terminal output as", suggested, "Text files (*.txt);;All files (*.*)")
592
+ if not path:
593
+ return
594
+ try:
595
+ with open(path, "w", encoding="utf-8", errors="replace") as f:
596
+ f.write(self.output.toPlainText())
597
+ except OSError:
598
+ pass
599
+
600
+ def _open_log(self) -> None:
601
+ if self._log_fp is None:
602
+ self._log_fp = self._log_path.open("a", encoding="utf-8", errors="replace")
603
+
604
+ def _write_log(self, text: str) -> None:
605
+ try:
606
+ if self._log_fp is None:
607
+ self._open_log()
608
+ if self._log_fp is not None:
609
+ self._log_fp.write(text)
610
+ self._log_fp.flush()
611
+ except Exception:
612
+ pass
613
+
614
+ def _close_log(self) -> None:
615
+ try:
616
+ if self._log_fp is not None:
617
+ self._log_fp.flush()
618
+ self._log_fp.close()
619
+ except Exception:
620
+ pass
621
+ self._log_fp = None
622
+
623
+
624
+ class TerminalTab(QWidget):
625
+ """MobaXterm-like workspace: device/serial bar, sidebar + tabbed sessions (dark terminal)."""
626
+
627
+ def __init__(
628
+ self,
629
+ get_adb_path: Callable[[], str],
630
+ get_default_ssh_host: Callable[[], str],
631
+ get_default_serial_port: Callable[[], str],
632
+ get_default_serial_baud: Callable[[], str],
633
+ config: AppConfig,
634
+ append_log: Optional[Callable[[str], None]] = None,
635
+ ):
636
+ super().__init__()
637
+ self.get_adb_path = get_adb_path
638
+ self.get_default_ssh_host = get_default_ssh_host
639
+ self.get_default_serial_port = get_default_serial_port
640
+ self.get_default_serial_baud = get_default_serial_baud
641
+ self.config = config
642
+ self._append_log = append_log or (lambda _m: None)
643
+ self._placeholder_tab: Optional[QWidget] = None
644
+ self._build_ui()
645
+
646
+ def _init_hidden_session_controls(self) -> None:
647
+ """ADB device list + serial fields for MainWindow (File Explorer, menus). Combo stays hidden in Terminal."""
648
+ self.device_combo = ExpandAllComboBox(self)
649
+ self.device_combo.setObjectName("SessionDeviceCombo")
650
+ self.device_combo.setMaxVisibleItems(20)
651
+ self.device_combo.hide()
652
+ self.serial_port_edit = QLineEdit(self)
653
+ self.serial_port_edit.setObjectName("TerminalSerialPortHidden")
654
+ self.serial_baud_edit = QLineEdit(self)
655
+ self.serial_baud_edit.setObjectName("TerminalSerialBaudHidden")
656
+ self.serial_port_edit.setText(self.config.default_serial_port or "COM3")
657
+ self.serial_baud_edit.setText(self.config.default_serial_baud or "115200")
658
+ self.serial_port_edit.hide()
659
+ self.serial_baud_edit.hide()
660
+
661
+ def get_adb_serial(self) -> str:
662
+ return self.current_adb_serial()
663
+
664
+ def current_adb_serial(self) -> str:
665
+ d = self.device_combo.currentData()
666
+ if d is not None and str(d).strip():
667
+ return str(d).strip()
668
+ return _serial_from_combo_text(self.device_combo.currentText())
669
+
670
+ def get_session_profile(self) -> SessionProfile:
671
+ return SessionProfile(
672
+ ConnectionKind.ANDROID_ADB,
673
+ adb_serial=self.current_adb_serial(),
674
+ )
675
+
676
+ def get_serial_session_profile(self) -> SessionProfile:
677
+ return SessionProfile(
678
+ ConnectionKind.SERIAL,
679
+ serial_port=self.serial_port_edit.text().strip() or self.get_default_serial_port(),
680
+ serial_baud=self.serial_baud_edit.text().strip() or self.get_default_serial_baud(),
681
+ )
682
+
683
+ def sync_serial_from_config(self) -> None:
684
+ self.serial_port_edit.setText(self.config.default_serial_port or "COM3")
685
+ self.serial_baud_edit.setText(self.config.default_serial_baud or "115200")
686
+
687
+ def _build_ui(self):
688
+ layout = QVBoxLayout(self)
689
+ layout.setContentsMargins(6, 6, 6, 6)
690
+ layout.setSpacing(4)
691
+
692
+ self._init_hidden_session_controls()
693
+
694
+ top_toolbar = QHBoxLayout()
695
+ top_toolbar.setSpacing(8)
696
+ st = self.style()
697
+ b_new = QPushButton("New Session…")
698
+ b_new.setObjectName("MobaToolBtn")
699
+ b_new.setIcon(st.standardIcon(QStyle.SP_FileDialogNewFolder))
700
+ b_new.clicked.connect(self.add_session_dialog)
701
+ top_toolbar.addWidget(b_new)
702
+ top_toolbar.addStretch(1)
703
+ layout.addLayout(top_toolbar)
704
+ start_hint = QLabel(
705
+ "How to start: click New Session, choose protocol/details, then open the terminal tab."
706
+ )
707
+ start_hint.setObjectName("MobaTabCtrlLabel")
708
+ start_hint.setWordWrap(True)
709
+ layout.addWidget(start_hint)
710
+
711
+ split = QSplitter()
712
+ layout.addWidget(split, 1)
713
+
714
+ left = QWidget()
715
+ left.setObjectName("MobaLeftPane")
716
+ left_layout = QVBoxLayout(left)
717
+ left_layout.setContentsMargins(8, 8, 8, 8)
718
+ left_layout.setSpacing(8)
719
+
720
+ bm_lbl = QLabel("Bookmarks")
721
+ bm_lbl.setObjectName("MobaSidebarHeading")
722
+ left_layout.addWidget(bm_lbl)
723
+ self.bookmark_list = QListWidget()
724
+ self.bookmark_list.setObjectName("MobaBookmarkList")
725
+ self.bookmark_list.setIconSize(QSize(20, 20))
726
+ self.bookmark_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
727
+ self.bookmark_list.itemDoubleClicked.connect(self._on_bookmark_double_clicked)
728
+ self.bookmark_list.setContextMenuPolicy(Qt.CustomContextMenu)
729
+ self.bookmark_list.customContextMenuRequested.connect(self._on_bookmark_context_menu)
730
+ left_layout.addWidget(self.bookmark_list, 1)
731
+ self._reload_bookmark_sidebar()
732
+
733
+ local_row = QHBoxLayout()
734
+ local_row.setSpacing(4)
735
+ for txt, fn, ic in [
736
+ ("CMD", self._open_local_cmd, icon_windows_cmd_console()),
737
+ ("PowerShell", self._open_local_powershell, icon_windows_powershell()),
738
+ ]:
739
+ b = QPushButton(txt)
740
+ b.setObjectName("MobaToolBtn")
741
+ b.setIcon(ic)
742
+ b.clicked.connect(fn)
743
+ local_row.addWidget(b)
744
+ left_layout.addLayout(local_row)
745
+
746
+ pin_row = QHBoxLayout()
747
+ pin_row.setSpacing(4)
748
+ b1 = QPushButton("Pin CMD")
749
+ b1.setObjectName("MobaToolBtn")
750
+ b1.setIcon(st.standardIcon(QStyle.SP_DialogSaveButton))
751
+ b1.setToolTip("Save Command Prompt as a bookmark")
752
+ b1.clicked.connect(self._pin_local_cmd_bookmark)
753
+ b2 = QPushButton("Pin PS")
754
+ b2.setObjectName("MobaToolBtn")
755
+ b2.setIcon(st.standardIcon(QStyle.SP_DialogSaveButton))
756
+ b2.setToolTip("Save PowerShell as a bookmark")
757
+ b2.clicked.connect(self._pin_local_pwsh_bookmark)
758
+ pin_row.addWidget(b1)
759
+ pin_row.addWidget(b2)
760
+ left_layout.addLayout(pin_row)
761
+
762
+ go = QPushButton("Login…")
763
+ go.setObjectName("MobaToolBtn")
764
+ go.setIcon(st.standardIcon(QStyle.SP_DialogOpenButton))
765
+ go.clicked.connect(self.add_session_dialog)
766
+ left_layout.addWidget(go)
767
+
768
+ right = QWidget()
769
+ right.setObjectName("MobaRightPane")
770
+ right_layout = QVBoxLayout(right)
771
+ right_layout.setContentsMargins(0, 0, 0, 0)
772
+ self.tabs = QTabWidget()
773
+ self.tabs.setObjectName("MobaTabs")
774
+ self.tabs.setTabsClosable(True)
775
+ self.tabs.setMovable(True)
776
+ tb = self.tabs.tabBar()
777
+ tb.setObjectName("MobaTabBar")
778
+ tb.setElideMode(Qt.ElideNone)
779
+ tb.setUsesScrollButtons(True)
780
+ tb.setContextMenuPolicy(Qt.CustomContextMenu)
781
+ tb.customContextMenuRequested.connect(self._on_terminal_tab_bar_context_menu)
782
+ self.tabs.tabCloseRequested.connect(self._on_tab_close)
783
+ right_layout.addWidget(self.tabs, 1)
784
+
785
+ status = QLabel("Ready")
786
+ status.setObjectName("MobaStatus")
787
+ right_layout.addWidget(status)
788
+
789
+ split.addWidget(left)
790
+ split.addWidget(right)
791
+ split.setStretchFactor(0, 0)
792
+ split.setStretchFactor(1, 1)
793
+ split.setSizes([240, 1000])
794
+
795
+ self._add_placeholder_tab()
796
+
797
+ def _ssh_tab_title(self, profile: SessionProfile) -> str:
798
+ h = (profile.ssh_host or "").strip()
799
+ u = (profile.ssh_user or "").strip()
800
+ p = int(profile.ssh_port or 22)
801
+ if u:
802
+ base = f"{u}@{h}"
803
+ else:
804
+ base = h or "SSH"
805
+ if p != 22:
806
+ return f"{base}:{p}"
807
+ return base
808
+
809
+ def _reload_bookmark_sidebar(self) -> None:
810
+ self.bookmark_list.clear()
811
+ for bm in self.config.session_bookmarks:
812
+ if not isinstance(bm, dict):
813
+ continue
814
+ if bm.get("kind") not in ("ssh", "adb", "serial", "local_cmd", "local_pwsh"):
815
+ continue
816
+ it = QListWidgetItem(bm.get("name") or "Untitled")
817
+ it.setIcon(bookmark_icon_from_entry(bm, self))
818
+ it.setData(Qt.UserRole, bm)
819
+ self.bookmark_list.addItem(it)
820
+
821
+ def _on_bookmark_double_clicked(self, item: QListWidgetItem) -> None:
822
+ bm = item.data(Qt.UserRole)
823
+ if isinstance(bm, dict):
824
+ self._open_bookmark(bm)
825
+
826
+ def _on_bookmark_context_menu(self, pos) -> None:
827
+ it = self.bookmark_list.itemAt(pos)
828
+ m = QMenu(self)
829
+ a_login = m.addAction("Start new session…")
830
+ a_login.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
831
+ a_login.triggered.connect(self.add_session_dialog)
832
+ if it is not None:
833
+ bm = it.data(Qt.UserRole)
834
+ if isinstance(bm, dict):
835
+ m.addSeparator()
836
+ a_open = m.addAction("Open bookmark")
837
+ a_open.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
838
+ a_open.triggered.connect(lambda: self._open_bookmark(bm))
839
+ a_new = m.addAction("Start new session from bookmark")
840
+ a_new.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
841
+ a_new.triggered.connect(lambda: self._open_bookmark(bm))
842
+ selected = [x for x in self.bookmark_list.selectedItems() if isinstance(x.data(Qt.UserRole), dict)]
843
+ if selected:
844
+ m.addSeparator()
845
+ a_del = m.addAction(f"Delete selected bookmark(s) ({len(selected)})")
846
+ a_del.setIcon(self.style().standardIcon(QStyle.SP_TrashIcon))
847
+ a_del.triggered.connect(self._delete_selected_bookmarks)
848
+ m.exec_(self.bookmark_list.mapToGlobal(pos))
849
+
850
+ def _delete_bookmark_by_name(self, name: str) -> None:
851
+ if not name:
852
+ return
853
+ self.config.session_bookmarks = [
854
+ x for x in self.config.session_bookmarks if isinstance(x, dict) and x.get("name") != name
855
+ ]
856
+ self.config.save()
857
+ self._reload_bookmark_sidebar()
858
+
859
+ def _delete_selected_bookmarks(self) -> None:
860
+ names = []
861
+ for it in self.bookmark_list.selectedItems():
862
+ bm = it.data(Qt.UserRole)
863
+ if isinstance(bm, dict):
864
+ n = str(bm.get("name", "")).strip()
865
+ if n:
866
+ names.append(n)
867
+ if not names:
868
+ return
869
+ drop = set(names)
870
+ self.config.session_bookmarks = [
871
+ x
872
+ for x in self.config.session_bookmarks
873
+ if not (isinstance(x, dict) and str(x.get("name", "")).strip() in drop)
874
+ ]
875
+ self.config.save()
876
+ self._reload_bookmark_sidebar()
877
+
878
+ def _open_bookmark(self, bm: dict) -> None:
879
+ k = bm.get("kind")
880
+ if k == "local_cmd":
881
+ self._open_local_cmd()
882
+ return
883
+ if k == "local_pwsh":
884
+ self._open_local_powershell()
885
+ return
886
+ if k == "adb":
887
+ serial = (bm.get("adb_serial") or "").strip()
888
+ if not serial:
889
+ QMessageBox.information(self, "Bookmark", "This bookmark has no device serial.")
890
+ return
891
+ adb = self.get_adb_path()
892
+ cmd = adb_interactive_shell_command(adb, serial)
893
+ label = (bm.get("adb_label") or "").strip() or f"{friendly_name_for_serial(adb, serial)} · {serial}"
894
+ self.add_session(label, cmd, banner=_adb_terminal_banner(adb, serial, label))
895
+ return
896
+ if k == "ssh":
897
+ try:
898
+ sp = int(bm.get("ssh_port") or 22)
899
+ except (TypeError, ValueError):
900
+ sp = 22
901
+ profile = SessionProfile(
902
+ ConnectionKind.SSH_SFTP,
903
+ ssh_host=bm.get("ssh_host", ""),
904
+ ssh_user=bm.get("ssh_user", ""),
905
+ ssh_port=sp,
906
+ ssh_password=bm.get("ssh_password", "") or "",
907
+ )
908
+ self.add_session(self._ssh_tab_title(profile), ssh_command_args(profile))
909
+ return
910
+ if k == "serial":
911
+ port = bm.get("serial_port") or self.get_default_serial_port()
912
+ baud = bm.get("serial_baud") or self.get_default_serial_baud()
913
+ cmd = [sys.executable, "-m", "serial.tools.miniterm", str(port), str(baud)]
914
+ self.add_session(f"Serial · {port}", cmd)
915
+
916
+ def _open_local_cmd(self) -> None:
917
+ # Small quality-of-life aliases so Linux-style habits still work in CMD.
918
+ cmd_exe = os.environ.get("COMSPEC", "cmd.exe")
919
+ # Chain with `&` — `$T` is for inside a doskey macro body, not two separate doskey commands.
920
+ self.add_session(
921
+ "Command Prompt",
922
+ [cmd_exe, "/K", "doskey ls=dir & doskey pwd=cd"],
923
+ working_dir=os.getcwd(),
924
+ shell_profile="cmd",
925
+ )
926
+
927
+ def _open_local_powershell(self) -> None:
928
+ sys_root = os.environ.get("SystemRoot", r"C:\Windows")
929
+ ps = Path(sys_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
930
+ ps_exe = str(ps) if ps.is_file() else "powershell.exe"
931
+ # Startup command: `lx` is a common typo for `ls` (helps when muscle memory slips).
932
+ self.add_session(
933
+ "Windows PowerShell",
934
+ [
935
+ ps_exe,
936
+ "-NoLogo",
937
+ "-NoProfile",
938
+ "-ExecutionPolicy",
939
+ "Bypass",
940
+ "-NoExit",
941
+ "-Command",
942
+ "Set-Alias -Name lx -Value Get-ChildItem -Scope Global",
943
+ ],
944
+ working_dir=os.getcwd(),
945
+ shell_profile="powershell",
946
+ )
947
+
948
+ def _next_bookmark_name(self, base: str) -> str:
949
+ names = {x.get("name") for x in self.config.session_bookmarks if isinstance(x, dict)}
950
+ if base not in names:
951
+ return base
952
+ n = 2
953
+ while f"{base} ({n})" in names:
954
+ n += 1
955
+ return f"{base} ({n})"
956
+
957
+ def _pin_local_cmd_bookmark(self) -> None:
958
+ name = self._next_bookmark_name("Command Prompt")
959
+ self.config.session_bookmarks.append({"name": name, "kind": "local_cmd"})
960
+ self.config.save()
961
+ self._reload_bookmark_sidebar()
962
+
963
+ def _pin_local_pwsh_bookmark(self) -> None:
964
+ name = self._next_bookmark_name("Windows PowerShell")
965
+ self.config.session_bookmarks.append({"name": name, "kind": "local_pwsh"})
966
+ self.config.save()
967
+ self._reload_bookmark_sidebar()
968
+
969
+ def _add_placeholder_tab(self) -> None:
970
+ if self._placeholder_tab is not None:
971
+ return
972
+ w = QWidget()
973
+ w.setObjectName("MobaRightPane")
974
+ lay = QVBoxLayout(w)
975
+ lay.setContentsMargins(20, 16, 20, 16)
976
+ msg = QLabel(
977
+ "No shell session yet.\n\n"
978
+ "Use the sidebar to open Command Prompt or PowerShell, or New Session for SSH or Android (ADB). "
979
+ "Plug in the device and use Session → Refresh if the list is empty."
980
+ )
981
+ msg.setWordWrap(True)
982
+ msg.setObjectName("MobaMenuText")
983
+ msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
984
+ lay.addStretch(1)
985
+ lay.addWidget(msg)
986
+ go = QPushButton("New Session…")
987
+ go.setObjectName("MobaToolBtn")
988
+ go.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
989
+ go.clicked.connect(self.add_session_dialog)
990
+ lay.addWidget(go, alignment=Qt.AlignHCenter)
991
+ lay.addStretch(2)
992
+ self._placeholder_tab = w
993
+ self.tabs.addTab(w, "Start")
994
+
995
+ def _on_tab_close(self, index: int) -> None:
996
+ w = self.tabs.widget(index)
997
+ if isinstance(w, SessionWidget):
998
+ w.shutdown()
999
+ if w is self._placeholder_tab:
1000
+ self._placeholder_tab = None
1001
+ self.tabs.removeTab(index)
1002
+ if self.tabs.count() == 0:
1003
+ self._add_placeholder_tab()
1004
+
1005
+ def _on_terminal_tab_bar_context_menu(self, pos) -> None:
1006
+ """Moba-style: right-click a session tab for close / close others / close to the side."""
1007
+ tb = self.tabs.tabBar()
1008
+ idx = tb.tabAt(pos)
1009
+ if idx < 0:
1010
+ return
1011
+ m = QMenu(self)
1012
+ a_close = m.addAction("Close")
1013
+ a_close.setToolTip("Close this tab")
1014
+ a_others = m.addAction("Close others")
1015
+ a_others.setToolTip("Close all tabs except this one")
1016
+ a_right = m.addAction("Close to the right")
1017
+ a_right.setToolTip("Close every tab to the right of this one")
1018
+ a_left = m.addAction("Close to the left")
1019
+ a_left.setToolTip("Close every tab to the left of this one")
1020
+ chosen = m.exec_(tb.mapToGlobal(pos))
1021
+ if chosen == a_close:
1022
+ self._remove_tab_at(idx)
1023
+ if self.tabs.count() == 0:
1024
+ self._add_placeholder_tab()
1025
+ elif chosen == a_others:
1026
+ self._close_all_tabs_except(idx)
1027
+ elif chosen == a_right:
1028
+ self._close_tabs_to_the_right_of(idx)
1029
+ elif chosen == a_left:
1030
+ self._close_tabs_to_the_left_of(idx)
1031
+
1032
+ def _close_all_tabs_except(self, keep_index: int) -> None:
1033
+ for i in range(self.tabs.count() - 1, -1, -1):
1034
+ if i != keep_index:
1035
+ self._remove_tab_at(i)
1036
+ if self.tabs.count() == 0:
1037
+ self._add_placeholder_tab()
1038
+
1039
+ def _close_tabs_to_the_right_of(self, index: int) -> None:
1040
+ for i in range(self.tabs.count() - 1, index, -1):
1041
+ self._remove_tab_at(i)
1042
+ if self.tabs.count() == 0:
1043
+ self._add_placeholder_tab()
1044
+
1045
+ def _close_tabs_to_the_left_of(self, index: int) -> None:
1046
+ for i in range(index - 1, -1, -1):
1047
+ self._remove_tab_at(i)
1048
+ if self.tabs.count() == 0:
1049
+ self._add_placeholder_tab()
1050
+
1051
+ def _remove_tab_at(self, index: int) -> None:
1052
+ """Remove tab by index; shutdown SessionWidget if present."""
1053
+ if index < 0 or index >= self.tabs.count():
1054
+ return
1055
+ w = self.tabs.widget(index)
1056
+ if isinstance(w, SessionWidget):
1057
+ w.shutdown()
1058
+ if w is self._placeholder_tab:
1059
+ self._placeholder_tab = None
1060
+ self.tabs.removeTab(index)
1061
+
1062
+ def add_session_dialog(self) -> None:
1063
+ dlg = SessionLoginDialog(
1064
+ self.get_adb_path,
1065
+ self.get_default_ssh_host(),
1066
+ self.current_adb_serial() or "",
1067
+ self,
1068
+ for_terminal=True,
1069
+ config=self.config,
1070
+ on_bookmarks_changed=self._reload_bookmark_sidebar,
1071
+ )
1072
+ if dlg.exec_() != QDialog.Accepted:
1073
+ return
1074
+ o = dlg.outcome()
1075
+ if o:
1076
+ self._apply_login_outcome(o)
1077
+
1078
+ def _apply_login_outcome(self, o: SessionLoginOutcome) -> None:
1079
+ if o.kind == "adb":
1080
+ adb = self.get_adb_path()
1081
+ serial = (o.adb_serial or "").strip()
1082
+ cmd = adb_interactive_shell_command(adb, serial or None)
1083
+ short = (o.adb_display_label or "").strip() or f"{friendly_name_for_serial(self.get_adb_path(), serial)} · {serial}"
1084
+ self.add_session(short, cmd, banner=_adb_terminal_banner(adb, serial, short))
1085
+ elif o.kind == "sftp":
1086
+ profile = SessionProfile(
1087
+ ConnectionKind.SSH_SFTP,
1088
+ ssh_host=o.sftp_host,
1089
+ ssh_user=o.sftp_user,
1090
+ ssh_port=o.sftp_port,
1091
+ ssh_password=o.sftp_password or "",
1092
+ )
1093
+ self.add_session(self._ssh_tab_title(profile), ssh_command_args(profile))
1094
+ elif o.kind == "serial":
1095
+ port = (o.serial_port or "").strip() or self.get_default_serial_port()
1096
+ baud = (o.serial_baud or "").strip() or self.get_default_serial_baud()
1097
+ cmd = [sys.executable, "-m", "serial.tools.miniterm", port, baud]
1098
+ self.add_session(f"Serial · {port}", cmd)
1099
+ elif o.kind == "local_cmd":
1100
+ self._open_local_cmd()
1101
+ elif o.kind == "local_pwsh":
1102
+ self._open_local_powershell()
1103
+
1104
+ def add_session(
1105
+ self,
1106
+ label: str,
1107
+ command: List[str],
1108
+ *,
1109
+ banner: Optional[str] = None,
1110
+ working_dir: Optional[str] = None,
1111
+ shell_profile: Optional[str] = None,
1112
+ ):
1113
+ if self._placeholder_tab is not None:
1114
+ idx = self.tabs.indexOf(self._placeholder_tab)
1115
+ if idx >= 0:
1116
+ self.tabs.removeTab(idx)
1117
+ self._placeholder_tab = None
1118
+ widget = SessionWidget(
1119
+ label,
1120
+ command,
1121
+ banner=banner,
1122
+ working_dir=working_dir,
1123
+ shell_profile=shell_profile,
1124
+ )
1125
+ idx = self.tabs.addTab(widget, label)
1126
+ self.tabs.setCurrentIndex(idx)
1127
+
1128
+ def close_session(self):
1129
+ idx = self.tabs.currentIndex()
1130
+ if idx < 0:
1131
+ return
1132
+ w = self.tabs.widget(idx)
1133
+ if isinstance(w, SessionWidget):
1134
+ w.shutdown()
1135
+ if w is self._placeholder_tab:
1136
+ self._placeholder_tab = None
1137
+ self.tabs.removeTab(idx)
1138
+ if self.tabs.count() == 0:
1139
+ self._add_placeholder_tab()
1140
+
1141
+ def send_line_to_current_session(self, line: str) -> bool:
1142
+ """Send one line to the active terminal tab. Returns False if no running session."""
1143
+ w = self.tabs.currentWidget()
1144
+ if not isinstance(w, SessionWidget):
1145
+ QMessageBox.information(
1146
+ self,
1147
+ "Terminal",
1148
+ "Open a terminal session tab first (e.g. Session → SSH → New SSH session).",
1149
+ )
1150
+ return False
1151
+ w.send_line(line)
1152
+ self.tabs.setCurrentWidget(w)
1153
+ return True
1154
+
1155
+ def shutdown_all_sessions(self) -> None:
1156
+ """Terminate every open shell (ADB, SSH, serial, local) before the application exits."""
1157
+ for i in range(self.tabs.count()):
1158
+ w = self.tabs.widget(i)
1159
+ if isinstance(w, SessionWidget):
1160
+ w.shutdown(wait_for_process=True)
1161
+
1162
+ def _open_current_connection(self):
1163
+ self.open_session_matching_profile(self.get_session_profile())
1164
+
1165
+ def open_session_matching_profile(self, profile: SessionProfile):
1166
+ if profile.kind == ConnectionKind.ANDROID_ADB:
1167
+ adb = self.get_adb_path()
1168
+ serial = (profile.adb_serial or "").strip()
1169
+ cmd = adb_interactive_shell_command(adb, serial or None)
1170
+ short = f"{friendly_name_for_serial(adb, serial)} · {serial}" if serial else "ADB"
1171
+ self.add_session(short, cmd, banner=_adb_terminal_banner(adb, serial, short))
1172
+ elif profile.kind == ConnectionKind.SSH_SFTP:
1173
+ if not (profile.ssh_host or "").strip():
1174
+ QMessageBox.information(
1175
+ self,
1176
+ "SSH",
1177
+ "Enter host in File Explorer → SFTP tab, then use "
1178
+ "Session → Open SSH terminal (from Explorer SFTP fields).",
1179
+ )
1180
+ return
1181
+ self.add_session(self._ssh_tab_title(profile), ssh_command_args(profile))
1182
+ elif profile.kind == ConnectionKind.FTP:
1183
+ QMessageBox.information(
1184
+ self,
1185
+ "FTP",
1186
+ "FTP is for file transfer in File Explorer. Use New Session for a shell.",
1187
+ )
1188
+ elif profile.kind == ConnectionKind.SERIAL:
1189
+ port = profile.serial_port or self.get_default_serial_port()
1190
+ baud = profile.serial_baud or self.get_default_serial_baud()
1191
+ cmd = [sys.executable, "-m", "serial.tools.miniterm", port, baud]
1192
+ self.add_session(f"Serial · {port}", cmd)