Client SSH/SFTP personnel pour Windows
Reunit en un seul outil les fonctions utiles de MobaXterm, Termius et Bitvise, sans compte ni service tiers, avec toutes les donnees en local. - Terminaux multi-onglets : sessions SSH et terminaux Windows locaux (PowerShell / cmd) via la pseudo-console native ConPTY - Gestionnaire de fichiers SFTP facon WinSCP : double panneau, colonnes triables, glisser-deposer, renommage, permissions, file de transferts. Ouvrable sur une connexion en cours sans creer de favori - Gestion des cles SSH : generation Ed25519/RSA, import, export - Gestion du fichier known_hosts, avec remplacement guide d'une cle d'hote modifiee (cas frequent des serveurs en DHCP) - Trois themes (clair, bleu nuit, noir), commutables a chaud - Authentification automatique reprenant le comportement de la commande ssh (agent SSH et cles ~/.ssh), mot de passe demande seulement en secours Aucun mot de passe n'est stocke sur le disque. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
266
app/terminal_widget.py
Normal file
266
app/terminal_widget.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Widget terminal : emulation ANSI/VT100 (pyte) rendue avec QPainter.
|
||||
|
||||
Optimisation cle : on ne redessine que les lignes reellement modifiees depuis le
|
||||
dernier rendu (via `screen.dirty`, fourni par pyte), au lieu de repeindre tout
|
||||
l'ecran a chaque octet recu. Sans ca, chaque frappe clavier (et son echo distant)
|
||||
declenche un repaint de la totalite de la grille, ce qui devient tres sensible
|
||||
des que le terminal contient beaucoup de lignes ou que la fenetre est grande.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pyte
|
||||
from PySide6.QtCore import QRect, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from app import theme as app_theme
|
||||
|
||||
# Sequences envoyees pour les touches speciales
|
||||
KEY_SEQUENCES = {
|
||||
Qt.Key_Up: b"\x1b[A", Qt.Key_Down: b"\x1b[B", Qt.Key_Right: b"\x1b[C", Qt.Key_Left: b"\x1b[D",
|
||||
Qt.Key_Home: b"\x1b[H", Qt.Key_End: b"\x1b[F", Qt.Key_Insert: b"\x1b[2~", Qt.Key_Delete: b"\x1b[3~",
|
||||
Qt.Key_PageUp: b"\x1b[5~", Qt.Key_PageDown: b"\x1b[6~",
|
||||
Qt.Key_F1: b"\x1bOP", Qt.Key_F2: b"\x1bOQ", Qt.Key_F3: b"\x1bOR", Qt.Key_F4: b"\x1bOS",
|
||||
Qt.Key_F5: b"\x1b[15~", Qt.Key_F6: b"\x1b[17~", Qt.Key_F7: b"\x1b[18~", Qt.Key_F8: b"\x1b[19~",
|
||||
Qt.Key_F9: b"\x1b[20~", Qt.Key_F10: b"\x1b[21~", Qt.Key_F11: b"\x1b[23~", Qt.Key_F12: b"\x1b[24~",
|
||||
Qt.Key_Backspace: b"\x7f", Qt.Key_Tab: b"\t", Qt.Key_Return: b"\r", Qt.Key_Enter: b"\r",
|
||||
Qt.Key_Escape: b"\x1b",
|
||||
}
|
||||
|
||||
|
||||
class ReplyingScreen(pyte.HistoryScreen):
|
||||
"""Ecran pyte capable de repondre aux requetes du terminal.
|
||||
|
||||
Certaines pseudo-consoles (ConPTY notamment) interrogent le terminal au demarrage
|
||||
("Device Attributes" \x1b[c, "Cursor Position Report" \x1b[6n) et **attendent la
|
||||
reponse** avant de poursuivre. pyte sait construire ces reponses, mais les envoie
|
||||
via write_process_input(), qui ne fait rien par defaut : sans ce branchement, la
|
||||
console reste bloquee en attente.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.reply_callback = None
|
||||
|
||||
def write_process_input(self, data: str) -> None:
|
||||
if self.reply_callback:
|
||||
self.reply_callback(data.encode("utf-8", errors="ignore"))
|
||||
|
||||
|
||||
class TerminalWidget(QWidget):
|
||||
data_to_send = Signal(bytes)
|
||||
resized = Signal(int, int) # cols, rows
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
self.setAttribute(Qt.WA_OpaquePaintEvent, True)
|
||||
|
||||
self.font_ = QFont("Cascadia Mono", 10)
|
||||
if not self.font_.exactMatch():
|
||||
self.font_ = QFont("Consolas", 10)
|
||||
self.font_.setStyleHint(QFont.Monospace)
|
||||
self.setFont(self.font_)
|
||||
metrics = QFontMetrics(self.font_)
|
||||
self.char_w = metrics.horizontalAdvance("M")
|
||||
self.char_h = metrics.height()
|
||||
|
||||
self.cols = 80
|
||||
self.rows = 24
|
||||
self.screen = ReplyingScreen(self.cols, self.rows, history=5000)
|
||||
# les reponses aux requetes du terminal repartent par le meme canal que le clavier
|
||||
self.screen.reply_callback = self.data_to_send.emit
|
||||
self.stream = pyte.ByteStream(self.screen)
|
||||
self.scroll_offset = 0
|
||||
self._prev_cursor_y = 0
|
||||
|
||||
self._load_palette()
|
||||
self.setMinimumSize(200, 100)
|
||||
|
||||
def _load_palette(self) -> None:
|
||||
"""Construit les couleurs du terminal a partir du theme courant.
|
||||
|
||||
Le cache evite de re-parser les memes couleurs hexa a chaque caractere
|
||||
redessine (c'etait un point chaud du rendu)."""
|
||||
palette = app_theme.terminal_palette()
|
||||
self.default_fg = QColor(palette["fg"])
|
||||
self.default_bg = QColor(palette["bg"])
|
||||
self.cursor_color = QColor(*palette["cursor"])
|
||||
self._color_cache: dict[str, QColor] = {
|
||||
name: QColor(value) for name, value in palette["ansi"].items()
|
||||
}
|
||||
self._bold_fg = QColor(palette["ansi"]["brightwhite"])
|
||||
|
||||
def refresh_theme(self) -> None:
|
||||
"""Recharge les couleurs apres un changement de theme, et redessine."""
|
||||
self._load_palette()
|
||||
self.update()
|
||||
|
||||
def buffer_text(self) -> str:
|
||||
"""Texte visible + historique proche, pour retrouver ce qui a ete tape
|
||||
(utilise pour deduire l'hote d'une commande ssh saisie a la main).
|
||||
|
||||
Les lignes remplies jusqu'a la derniere colonne sont recollees avec la
|
||||
suivante : une commande longue (prompt Windows + ssh user@ip) deborde sur
|
||||
deux lignes, et la couper en plein milieu casserait la detection.
|
||||
"""
|
||||
lines = list(self.screen.display)
|
||||
try:
|
||||
for entry in self.screen.history.top:
|
||||
lines.insert(0, "".join(char.data for char in entry.values()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
joined: list[str] = []
|
||||
current = ""
|
||||
for line in lines:
|
||||
stripped = line.rstrip()
|
||||
current += stripped
|
||||
if len(stripped) >= self.cols: # ligne pleine : la suite continue en dessous
|
||||
continue
|
||||
joined.append(current)
|
||||
current = ""
|
||||
if current:
|
||||
joined.append(current)
|
||||
return "\n".join(joined)
|
||||
|
||||
# ---------- alimentation depuis la connexion SSH ----------
|
||||
def feed(self, data: bytes) -> None:
|
||||
was_scrolled = self.scroll_offset != 0
|
||||
self.stream.feed(data)
|
||||
self.scroll_offset = 0
|
||||
|
||||
if was_scrolled:
|
||||
# on revient du scrollback a la vue live : tout le contenu affiche change,
|
||||
# un repaint partiel base sur les seules lignes "dirty" serait incomplet
|
||||
self.screen.dirty.clear()
|
||||
self.update()
|
||||
return
|
||||
|
||||
dirty_rows = self.screen.dirty
|
||||
cursor_y = self.screen.cursor.y
|
||||
if cursor_y != self._prev_cursor_y:
|
||||
dirty_rows = dirty_rows | {self._prev_cursor_y, cursor_y}
|
||||
else:
|
||||
dirty_rows = dirty_rows | {cursor_y}
|
||||
self._prev_cursor_y = cursor_y
|
||||
|
||||
if dirty_rows:
|
||||
min_y = max(0, min(dirty_rows))
|
||||
max_y = min(self.rows - 1, max(dirty_rows))
|
||||
self.update(QRect(0, min_y * self.char_h, self.cols * self.char_w, (max_y - min_y + 1) * self.char_h))
|
||||
self.screen.dirty.clear()
|
||||
|
||||
# ---------- rendu ----------
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
rect = event.rect()
|
||||
painter.fillRect(rect, self.default_bg)
|
||||
painter.setFont(self.font_)
|
||||
|
||||
y0 = max(0, rect.top() // self.char_h)
|
||||
y1 = min(self.rows - 1, rect.bottom() // self.char_h)
|
||||
|
||||
cursor = self.screen.cursor
|
||||
for y in range(y0, y1 + 1):
|
||||
line = self.screen.buffer[y]
|
||||
x = 0
|
||||
while x < self.cols:
|
||||
char = line[x]
|
||||
run_text = char.data
|
||||
start_x = x
|
||||
fg, bg = self._resolve_colors(char)
|
||||
# regrouper les caracteres consecutifs de meme style pour limiter les appels de dessin
|
||||
x += 1
|
||||
while x < self.cols:
|
||||
nxt = line[x]
|
||||
nfg, nbg = self._resolve_colors(nxt)
|
||||
if nfg == fg and nbg == bg:
|
||||
run_text += nxt.data
|
||||
x += 1
|
||||
else:
|
||||
break
|
||||
|
||||
px = start_x * self.char_w
|
||||
py = y * self.char_h
|
||||
run_w = (x - start_x) * self.char_w
|
||||
if bg != self.default_bg:
|
||||
painter.fillRect(px, py, run_w, self.char_h, bg)
|
||||
painter.setPen(fg)
|
||||
painter.drawText(px, py + self.char_h - 4, run_text)
|
||||
|
||||
# curseur (bloc plein), non affiche pendant un scroll dans l'historique
|
||||
if self.scroll_offset == 0 and not self.screen.cursor.hidden and y0 <= cursor.y <= y1:
|
||||
cx, cy = cursor.x, cursor.y
|
||||
painter.fillRect(cx * self.char_w, cy * self.char_h, self.char_w, self.char_h, self.cursor_color)
|
||||
|
||||
painter.end()
|
||||
|
||||
def _resolve_colors(self, char) -> tuple[QColor, QColor]:
|
||||
fg_color = self._color_for(char.fg, self.default_fg)
|
||||
bg_color = self._color_for(char.bg, self.default_bg)
|
||||
if char.reverse:
|
||||
fg_color, bg_color = bg_color, fg_color
|
||||
if char.bold and fg_color is self.default_fg:
|
||||
fg_color = self._bold_fg
|
||||
return fg_color, bg_color
|
||||
|
||||
def _color_for(self, value: str, default: QColor) -> QColor:
|
||||
cached = self._color_cache.get(value)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if isinstance(value, str) and len(value) == 6:
|
||||
candidate = QColor(f"#{value}")
|
||||
if candidate.isValid():
|
||||
self._color_cache[value] = candidate
|
||||
return candidate
|
||||
return default
|
||||
|
||||
# ---------- redimensionnement ----------
|
||||
def resizeEvent(self, event) -> None:
|
||||
new_cols = max(10, self.width() // self.char_w)
|
||||
new_rows = max(3, self.height() // self.char_h)
|
||||
if new_cols != self.cols or new_rows != self.rows:
|
||||
self.cols, self.rows = new_cols, new_rows
|
||||
self.screen.resize(self.rows, self.cols)
|
||||
self.resized.emit(self.cols, self.rows)
|
||||
super().resizeEvent(event)
|
||||
|
||||
# ---------- entree clavier ----------
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
key = event.key()
|
||||
modifiers = event.modifiers()
|
||||
|
||||
# Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce :
|
||||
# sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper
|
||||
# @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble.
|
||||
# Sans cette exclusion, taper "@" (et les lettres voisines pendant le chevauchement
|
||||
# naturel de la frappe) etait intercepte comme un raccourci Ctrl+lettre parasite,
|
||||
# envoyant un octet de controle au lieu du vrai caractere.
|
||||
if (modifiers & Qt.ControlModifier) and not (modifiers & Qt.AltModifier) and Qt.Key_A <= key <= Qt.Key_Z:
|
||||
self.data_to_send.emit(bytes([key - Qt.Key_A + 1]))
|
||||
return
|
||||
|
||||
if key in KEY_SEQUENCES:
|
||||
self.data_to_send.emit(KEY_SEQUENCES[key])
|
||||
return
|
||||
|
||||
text = event.text()
|
||||
if text:
|
||||
self.data_to_send.emit(text.encode("utf-8", errors="ignore"))
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||
steps = event.angleDelta().y() // 120
|
||||
if steps > 0:
|
||||
self.screen.prev_page()
|
||||
self.scroll_offset += steps
|
||||
elif steps < 0:
|
||||
self.screen.next_page()
|
||||
self.scroll_offset = max(0, self.scroll_offset + steps)
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
self.setFocus()
|
||||
super().mousePressEvent(event)
|
||||
Reference in New Issue
Block a user