From b322b95aa4a5a1f1740cb219a160015160250612 Mon Sep 17 00:00:00 2001 From: snoopy Date: Fri, 4 Sep 2026 11:45:03 +0200 Subject: [PATCH] Terminaux locaux : detecter et proposer les shells Unix du poste L'emulateur de terminal etait deja le notre (pyte + QPainter) ; ce qui manquait pour se rapprocher de MobaXterm, c'etait le *shell*. MobaXterm ne fournit pas "son propre terminal" mais son propre bash, issu d'un Cygwin embarque : c'est ce qui donne ls/grep/sed/awk/vi sous Windows. Plutot que d'embarquer un binaire tiers, l'application detecte maintenant les shells deja installes et les propose tous : PowerShell, cmd, Bash de Git for Windows, MSYS2, Cygwin, WSL, et un busybox-w32 pose a cote de l'executable pour les postes qui n'ont rien. Un shell Unix est choisi par defaut quand il y en a un. Deux details qui bloquaient : - pywinpty ne sait pas decouper une commande passee en chaine avec des guillemets, et les chemins Windows en contiennent ("Program Files") : les commandes sont donc des listes d'arguments. - le bash de MSYS2 demarre sans jamais afficher d'invite si MSYSTEM n'est pas definie ; chaque shell peut donc declarer ses variables d'environnement (CHERE_INVOKING pour demarrer dans le dossier courant). Les menus et l'arborescence sont generes depuis la detection, ils s'adaptent donc au poste sans configuration. Co-Authored-By: Claude Opus 5 --- README.md | 7 +- app/local_terminal.py | 105 +- app/main_window.py | 3394 ++++++++++++++++++++++++++++++----------- 3 files changed, 2641 insertions(+), 865 deletions(-) diff --git a/README.md b/README.md index 2d0d576..ca65330 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,12 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com avec des serveurs de test qui redemarrent souvent - Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont reconnus : pas de double confirmation d'empreinte. Les nouvelles cles vont dans le fichier propre a l'application -- Terminaux locaux Windows (PowerShell / Invite de commandes) accessibles en un clic, **sans avoir besoin d'une session enregistree** — pratique pour un usage ponctuel, comme le terminal Windows natif +- Terminaux locaux accessibles en un clic, **sans session enregistree**. L'application detecte les + shells presents sur le poste et les propose tous : PowerShell, cmd, et surtout les **shells Unix** + s'ils sont installes (Bash de Git for Windows, MSYS2, Cygwin, WSL, ou un busybox pose a cote de + l'application). Comme le terminal local de MobaXterm, on retrouve alors `ls`, `grep`, `sed`, `awk`, + `vi`, `scp`... sous Windows. **Un shell Unix est choisi par defaut** quand il y en a un ; + sinon PowerShell. Rien n'est embarque : l'application reutilise ce qui est deja installe - Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic - Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee) - Client SFTP integre facon WinSCP : double panneau local/distant avec colonnes triables (nom, taille, date, droits), diff --git a/app/local_terminal.py b/app/local_terminal.py index 791d521..a93127b 100644 --- a/app/local_terminal.py +++ b/app/local_terminal.py @@ -5,25 +5,110 @@ fonctionnel en un clic, comme le fait MobaXterm avec ses "terminaux locaux". """ from __future__ import annotations +import os import queue +import shutil +import subprocess import threading +from pathlib import Path import winpty from PySide6.QtCore import QThread, Signal SHELL_POWERSHELL = "powershell" SHELL_CMD = "cmd" +SHELL_GITBASH = "gitbash" +SHELL_MSYS2 = "msys2" +SHELL_CYGWIN = "cygwin" +SHELL_WSL = "wsl" +SHELL_BUSYBOX = "busybox" -SHELL_LABELS = { - SHELL_POWERSHELL: "PowerShell", - SHELL_CMD: "Invite de commandes (cmd)", -} -SHELL_COMMANDS = { - SHELL_POWERSHELL: "powershell.exe -NoLogo", - SHELL_CMD: "cmd.exe", +# Shells Unix cherches sur le poste. Comme MobaXterm, on veut pouvoir travailler avec +# ls/grep/sed/awk/vi sous Windows ; mais plutot que d'embarquer un binaire tiers, on +# reutilise ce qui est deja installe (Git for Windows en fournit un tres complet). +# CHERE_INVOKING : demarrer dans le dossier courant plutot que dans le home. +# MSYSTEM : sans elle, le bash de MSYS2 se lance mais n'affiche jamais d'invite. +_UNIX_CANDIDATES = [ + (SHELL_GITBASH, "Bash (Git for Windows)", [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ], ["--login", "-i"], {"CHERE_INVOKING": "1"}), + (SHELL_MSYS2, "Bash (MSYS2)", [r"C:\msys64\usr\bin\bash.exe"], ["--login", "-i"], + {"CHERE_INVOKING": "1", "MSYSTEM": "MSYS"}), + (SHELL_CYGWIN, "Bash (Cygwin)", [r"C:\cygwin64\bin\bash.exe"], ["--login", "-i"], + {"CHERE_INVOKING": "1"}), +] + +# Les commandes sont des listes d'arguments : pywinpty ne sait pas decouper une chaine +# contenant des guillemets, et les chemins Windows en contiennent ("Program Files"). +_BASE_SHELLS = { + SHELL_POWERSHELL: ("PowerShell", ["powershell.exe", "-NoLogo"], {}), + SHELL_CMD: ("Invite de commandes (cmd)", ["cmd.exe"], {}), } +def _busybox_path() -> str | None: + """busybox-w32 pose a cote de l'application (pour les postes sans Git installe).""" + from app.paths import DATA_DIR + + for candidate in ( + DATA_DIR.parent / "busybox.exe", + DATA_DIR.parent / "busybox64.exe", + shutil.which("busybox") or "", + ): + if candidate and Path(candidate).exists(): + return str(candidate) + return None + + +def _wsl_available() -> bool: + if not shutil.which("wsl.exe"): + return False + try: + out = subprocess.run( + ["wsl.exe", "-l", "-q"], capture_output=True, timeout=3, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + # la sortie de wsl est en UTF-16 + return bool(out.stdout.decode("utf-16-le", errors="ignore").strip()) + except Exception: + return False + + +def discover_shells() -> dict[str, tuple[str, list[str], dict[str, str]]]: + """Renvoie les shells disponibles : {identifiant: (libelle, arguments, env)}.""" + shells = dict(_BASE_SHELLS) + for shell_id, label, paths, args, env in _UNIX_CANDIDATES: + for path in paths: + if Path(path).exists(): + shells[shell_id] = (label, [path] + args, env) + break + busybox = _busybox_path() + if busybox: + shells[SHELL_BUSYBOX] = ("Shell BusyBox", [busybox, "sh"], {}) + if _wsl_available(): + shells[SHELL_WSL] = ("WSL (Linux)", ["wsl.exe"], {}) + return shells + + +SHELLS = discover_shells() +SHELL_LABELS = {k: v[0] for k, v in SHELLS.items()} +SHELL_COMMANDS = {k: v[1] for k, v in SHELLS.items()} +SHELL_ENV = {k: v[2] for k, v in SHELLS.items()} + + +def default_shell() -> str: + """Shell ouvert par defaut : un shell Unix s'il y en a un sur le poste (on y gagne + ls/grep/sed/awk/ssh/scp, comme le terminal local de MobaXterm), sinon PowerShell.""" + for candidate in (SHELL_GITBASH, SHELL_MSYS2, SHELL_CYGWIN, SHELL_BUSYBOX): + if candidate in SHELLS: + return candidate + return SHELL_POWERSHELL + + +DEFAULT_SHELL = default_shell() + + class LocalTerminalConnection(QThread): """Meme interface de signaux que SSHConnection, pour reutiliser TerminalWidget tel quel.""" @@ -83,7 +168,7 @@ class LocalTerminalConnection(QThread): finally: self._cleanup() - def _spawn(self, command: str) -> winpty.PtyProcess: + def _spawn(self, command: list[str]) -> winpty.PtyProcess: """Ouvre la pseudo-console, en privilegiant ConPTY. ConPTY est l'API pseudo-console native de Windows (10 1809+) : c'est un vrai @@ -93,11 +178,14 @@ class LocalTerminalConnection(QThread): artefacts de curseur qui saute et de saisie reecrite. On ne retombe dessus que si ConPTY est indisponible (Windows trop ancien). """ + extra = SHELL_ENV.get(self.shell) or {} + env = {**os.environ, **extra} if extra else None try: return winpty.PtyProcess.spawn( command, dimensions=(self.rows, self.cols), cwd=self.cwd, + env=env, backend=winpty.Backend.ConPTY, ) except Exception: @@ -105,6 +193,7 @@ class LocalTerminalConnection(QThread): command, dimensions=(self.rows, self.cols), cwd=self.cwd, + env=env, backend=winpty.Backend.WinPTY, ) diff --git a/app/main_window.py b/app/main_window.py index 3812597..8686dd9 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -1,856 +1,2538 @@ -"""Fenetre principale : arborescence des sessions, onglets terminal/SFTP.""" -from __future__ import annotations - -import copy -import re -import uuid -from datetime import datetime -from pathlib import Path - -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QAction, QActionGroup, QKeySequence, QShortcut -from PySide6.QtWidgets import ( - QMainWindow, QDockWidget, QTreeWidget, QTreeWidgetItem, QTabWidget, QWidget, - QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMenu, QMessageBox, QSplitter, - QStatusBar, QFrame, QApplication, QLineEdit, QFileDialog, -) - -from app.session_manager import SessionStore, Session, AUTH_KEY, AUTH_PASSWORD -from app.key_manager import KeyManager -from app.ssh_connection import SSHConnection -from app.local_terminal import LocalTerminalConnection, SHELL_POWERSHELL, SHELL_CMD, SHELL_LABELS -from app.terminal_widget import TerminalWidget -from app.sftp_panel import SFTPPanel -from app.dialogs.session_dialog import SessionDialog -from app.dialogs.key_dialog import KeyManagerDialog -from app.dialogs.known_hosts_dialog import KnownHostsDialog -from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change -from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme - -SESSION_ROLE = Qt.UserRole + 10 -LOCAL_SHELL_ROLE = Qt.UserRole + 11 - -# Repere une commande ssh tapee a la main dans un terminal, pour proposer le SFTP -# sur cet hote sans avoir a creer de favori. -SSH_COMMAND_RE = re.compile(r"\bssh\s+(?P[^\r\n|;&]+)") -# options ssh qui consomment la valeur suivante (a ne pas confondre avec l'hote) -SSH_VALUE_OPTIONS = { - "-p", "-i", "-l", "-o", "-F", "-J", "-b", "-c", "-D", "-L", "-R", "-W", "-e", "-m", "-Q", -} -HOST_RE = re.compile(r"^[A-Za-z0-9][\w.\-]*$") -# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles -ANSI_ESCAPE_RE = re.compile( - r"\x1b\[[0-9;?]*[a-zA-Z]" # CSI : couleurs, deplacements du curseur - r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC : titre de fenetre - r"|\x1b[()][A-Z0-9]" # jeux de caracteres - r"|\x1b[=>]" # mode clavier - r"|[\x00-\x08\x0b\x0c\x0e-\x1f]" # autres caracteres de controle -) - - -def _parse_ssh_args(args: str) -> dict | None: - tokens = args.split() - user, host, port = "", "", 22 - index = 0 - while index < len(tokens): - token = tokens[index] - if token.startswith("-"): - if token in SSH_VALUE_OPTIONS: - value = tokens[index + 1] if index + 1 < len(tokens) else "" - if token == "-p" and value.isdigit(): - port = int(value) - if token == "-l" and value: - user = value - index += 2 - continue - if token.startswith("-p") and token[2:].isdigit(): # forme collee : -p2222 - port = int(token[2:]) - index += 1 - continue - if not host: - candidate = token - if "@" in candidate: - user, _, candidate = candidate.partition("@") - if HOST_RE.match(candidate): - host = candidate - index += 1 - return {"user": user, "host": host, "port": port} if host else None - - -def detect_ssh_target(text: str) -> dict | None: - """Renvoie la derniere cible ssh trouvee dans le texte d'un terminal.""" - result = None - for match in SSH_COMMAND_RE.finditer(text): - parsed = _parse_ssh_args(match.group("args")) - if parsed: - result = parsed - return result - - -class BaseTerminalTab(QWidget): - """Base commune a un onglet terminal : barre de statut + terminal, autour d'une - connexion (SSH ou locale) qui expose la meme interface de signaux - (connected / data_received / disconnected / error / send / resize).""" - - def __init__(self, initial_status: str, parent=None): - super().__init__(parent) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self.bar = QHBoxLayout() - self.bar.setContentsMargins(10, 6, 10, 6) - self.status_label = QLabel(initial_status) - self.status_label.setObjectName("statusLabel") - self.bar.addWidget(self.status_label, 1) - - # Apparait quand la connexion tombe : les serveurs de test redemarrent souvent, - # inutile de fermer l'onglet et de tout ressaisir. - self.reconnect_btn = QToolButton() - self.reconnect_btn.setObjectName("primary") - self.reconnect_btn.setText("⟳ Reconnecter") - self.reconnect_btn.hide() - self.reconnect_btn.clicked.connect(self.reconnect) - self.bar.addWidget(self.reconnect_btn) - - self.log_btn = QToolButton() - self.log_btn.setText("⏺ Journal") - self.log_btn.setToolTip("Enregistrer la session dans un fichier") - self.log_btn.setCheckable(True) - self.log_btn.clicked.connect(self.toggle_logging) - self.bar.addWidget(self.log_btn) - bar_frame = QFrame() - bar_frame.setObjectName("tabBar") - bar_frame.setLayout(self.bar) - layout.addWidget(bar_frame) - - self.splitter = QSplitter(Qt.Horizontal) - self.terminal = TerminalWidget() - self.splitter.addWidget(self.terminal) - layout.addWidget(self.splitter, 1) - - # barre de recherche, masquee tant qu'on n'a pas fait Ctrl+F - self.search_bar = QFrame() - self.search_bar.setObjectName("tabBar") - search_layout = QHBoxLayout(self.search_bar) - search_layout.setContentsMargins(10, 4, 10, 4) - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Rechercher dans l'historique... (Entree = precedent, Echap = fermer)") - self.search_status = QLabel("") - self.search_status.setObjectName("statusLabel") - close_search = QToolButton() - close_search.setText("✕") - search_layout.addWidget(self.search_input, 1) - search_layout.addWidget(self.search_status) - search_layout.addWidget(close_search) - self.search_bar.hide() - layout.addWidget(self.search_bar) - - self.terminal.search_requested.connect(self.open_search) - self.search_input.returnPressed.connect(self._run_search) - close_search.clicked.connect(self.close_search) - QShortcut(QKeySequence(Qt.Key_Escape), self.search_input, activated=self.close_search) - - self.connection = None # assigne par les sous-classes - self._log_file = None - - # ---------- journalisation ---------- - def toggle_logging(self) -> None: - if self._log_file: - self.stop_logging() - return - default = Path.home() / f"session-{datetime.now():%Y%m%d-%H%M%S}.log" - path, _ = QFileDialog.getSaveFileName(self, "Journaliser la session", str(default), "Journal (*.log)") - if not path: - self.log_btn.setChecked(False) - return - try: - self._log_file = open(path, "a", encoding="utf-8", errors="replace") - except OSError as exc: - QMessageBox.critical(self, "Erreur", f"Impossible d'ecrire le journal : {exc}") - self.log_btn.setChecked(False) - return - self._log_file.write(f"\n===== Session du {datetime.now():%d/%m/%Y %H:%M:%S} =====\n") - self.log_btn.setChecked(True) - self.log_btn.setToolTip(f"Journalisation active : {path}") - - def stop_logging(self) -> None: - if self._log_file: - try: - self._log_file.close() - except OSError: - pass - self._log_file = None - self.log_btn.setChecked(False) - self.log_btn.setToolTip("Enregistrer la session dans un fichier") - - def _log_output(self, data: bytes) -> None: - if not self._log_file: - return - # on retire les sequences d'echappement : un journal doit rester lisible - text = ANSI_ESCAPE_RE.sub("", data.decode("utf-8", errors="replace")) - try: - self._log_file.write(text) - self._log_file.flush() - except OSError: - self.stop_logging() - - def _wire_connection(self) -> None: - self.connection.connected.connect(self._on_connected) - self.connection.data_received.connect(self.terminal.feed) - self.connection.data_received.connect(self._log_output) - self.connection.disconnected.connect(self._on_disconnected) - self.connection.error.connect(self._on_error) - self.terminal.data_to_send.connect(self.connection.send) - self.terminal.resized.connect(self.connection.resize) - - def _create_connection(self): - """Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui - permet de la recreer a l'identique lors d'une reconnexion.""" - raise NotImplementedError - - def start_connection(self) -> None: - self.connection = self._create_connection() - self._wire_connection() - self.connection.start() - - def reconnect(self) -> None: - if self.connection: - self.connection.close_connection() - self.connection.wait(1500) - self.terminal.reset_screen() - self.reconnect_btn.hide() - self.status_label.setText("○ Reconnexion...") - self.start_connection() - - def _on_connected(self) -> None: - self.reconnect_btn.hide() - self.terminal.setFocus() - - def _on_disconnected(self, reason: str) -> None: - self.status_label.setText(f"● Deconnecte ({reason})") - self.reconnect_btn.show() - - def _on_error(self, message: str) -> None: - self.status_label.setText(f"● Erreur : {message}") - self.reconnect_btn.show() - QMessageBox.critical(self, "Erreur", message) - - # ---------- recherche dans l'historique ---------- - def open_search(self) -> None: - self.search_bar.show() - self.search_input.setFocus() - self.search_input.selectAll() - - def close_search(self) -> None: - self.search_bar.hide() - self.search_status.clear() - self.terminal.setFocus() - - def _run_search(self) -> None: - needle = self.search_input.text() - if not needle: - return - found = self.terminal.find_text(needle) - self.search_status.setText("" if found else "aucun resultat") - - def close_connection(self) -> None: - self.stop_logging() - if self.connection: - self.connection.close_connection() - self.connection.wait(2000) - - -class SessionTabWidget(BaseTerminalTab): - """Un onglet = une connexion SSH avec son terminal et un panneau SFTP optionnel.""" - - def __init__(self, session: Session, secret: str, parent=None): - super().__init__(f"○ Connexion a {session.username}@{session.host}...", parent) - self.session = session - self.sftp_panel: SFTPPanel | None = None - - self.sftp_toggle = QToolButton() - self.sftp_toggle.setText("📁 Fichiers (SFTP)") - self.sftp_toggle.setToolTip("Afficher / masquer le navigateur de fichiers SFTP") - self.sftp_toggle.setCheckable(True) - self.sftp_toggle.setChecked(True) - self.sftp_toggle.setEnabled(False) - self.bar.addWidget(self.sftp_toggle) - - self._secret = secret - self.sftp_toggle.toggled.connect(self._toggle_sftp) - self.start_connection() - - def _create_connection(self): - session = self.session - connection = SSHConnection( - host=session.host, - port=session.port, - username=session.username, - auth_type=session.auth_type, - key_path=session.key_path, - password=self._secret if session.auth_type == AUTH_PASSWORD else "", - passphrase=self._secret if session.auth_type == AUTH_KEY else "", - # comme MobaXterm : le navigateur de fichiers est la des l'ouverture, - # pas cache derriere une action supplementaire - open_sftp_on_connect=True, - ) - connection.sftp_ready.connect(self._on_sftp_ready) - connection.password_prompt.connect(self._on_password_prompt) - connection.host_key_prompt.connect(self._on_host_key_prompt) - connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt) - connection.passphrase_prompt.connect(self._on_passphrase_prompt) - return connection - - def _on_connected(self) -> None: - self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}") - # le bouton n'est active qu'une fois le canal SFTP reellement ouvert - # (_on_sftp_ready) : certains serveurs n'exposent pas le sous-systeme SFTP - super()._on_connected() - - def _on_disconnected(self, reason: str) -> None: - super()._on_disconnected(reason) - self.sftp_toggle.setEnabled(False) - - def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None: - accepted = confirm_host_key(self, hostname, key_type, fingerprint) - self.connection.confirm_host_key(accepted) - - def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None: - accepted = confirm_host_key_change(self, hostname, key_type, old_fp, new_fp) - self.connection.confirm_host_key_change(accepted) - - def _on_passphrase_prompt(self, key_path: str) -> None: - text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}") - self.connection.provide_passphrase(text if ok else None) - - def _on_password_prompt(self, target: str) -> None: - text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :") - self.connection.provide_password(text if ok else None) - - def _on_sftp_ready(self, sftp) -> None: - """Le canal SFTP a ete ouvert en arriere-plan : on affiche le navigateur de - fichiers a cote du terminal, sans action de l'utilisateur.""" - if self.sftp_panel is not None: - return - self.sftp_panel = SFTPPanel(sftp) - self.splitter.insertWidget(0, self.sftp_panel) - self.splitter.setSizes([460, 540]) - self.sftp_toggle.setEnabled(True) - self.sftp_toggle.setChecked(True) - self.terminal.setFocus() - - def _toggle_sftp(self, checked: bool) -> None: - if self.sftp_panel is not None: - self.sftp_panel.setVisible(checked) - - -class SFTPTabWidget(QWidget): - """Onglet SFTP autonome (facon WinSCP) : double panneau de fichiers, sans terminal.""" - - def __init__(self, session: Session, secret: str, parent=None): - super().__init__(parent) - self.session = session - self.sftp_panel: SFTPPanel | None = None - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - bar = QHBoxLayout() - bar.setContentsMargins(10, 6, 10, 6) - self.status_label = QLabel(f"○ Connexion SFTP a {session.username}@{session.host}...") - self.status_label.setObjectName("statusLabel") - bar.addWidget(self.status_label, 1) - bar_frame = QFrame() - bar_frame.setObjectName("tabBar") - bar_frame.setLayout(bar) - layout.addWidget(bar_frame) - - self.container = QVBoxLayout() - holder = QWidget() - holder.setLayout(self.container) - layout.addWidget(holder, 1) - - self.connection = SSHConnection( - host=session.host, - port=session.port, - username=session.username, - auth_type=session.auth_type, - key_path=session.key_path, - password=secret if session.auth_type == AUTH_PASSWORD else "", - passphrase=secret if session.auth_type == AUTH_KEY else "", - open_shell=False, - open_sftp_on_connect=True, - ) - self.connection.sftp_ready.connect(self._on_sftp_ready) - self.connection.password_prompt.connect(self._on_password_prompt) - self.connection.error.connect(self._on_error) - self.connection.disconnected.connect(self._on_disconnected) - self.connection.host_key_prompt.connect(self._on_host_key_prompt) - self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt) - self.connection.passphrase_prompt.connect(self._on_passphrase_prompt) - self.connection.start() - - def _on_sftp_ready(self, sftp) -> None: - self.sftp_panel = SFTPPanel(sftp) - self.container.addWidget(self.sftp_panel) - self.status_label.setText( - f"● SFTP connecte : {self.session.username}@{self.session.host}:{self.session.port}" - ) - - def _on_disconnected(self, reason: str) -> None: - self.status_label.setText(f"● Deconnecte ({reason})") - - def _on_error(self, message: str) -> None: - self.status_label.setText(f"● Erreur : {message}") - QMessageBox.critical(self, "Erreur SFTP", message) - - def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None: - self.connection.confirm_host_key(confirm_host_key(self, hostname, key_type, fingerprint)) - - def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None: - self.connection.confirm_host_key_change( - confirm_host_key_change(self, hostname, key_type, old_fp, new_fp) - ) - - def _on_passphrase_prompt(self, key_path: str) -> None: - text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}") - self.connection.provide_passphrase(text if ok else None) - - def _on_password_prompt(self, target: str) -> None: - text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :") - self.connection.provide_password(text if ok else None) - - def close_connection(self) -> None: - self.connection.close_connection() - self.connection.wait(2000) - - -class LocalTerminalTabWidget(BaseTerminalTab): - """Un onglet = un terminal Windows local (PowerShell / cmd), sans session enregistree.""" - - sftp_requested = Signal(object) # cible ssh detectee (ou None) - - def __init__(self, shell: str = SHELL_POWERSHELL, parent=None): - super().__init__(f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...", parent) - self.shell = shell - - # Meme dans un terminal local, on tape souvent "ssh user@ip" a la main : - # ce bouton ouvre le SFTP sur cet hote, sans passer par un favori. - self.sftp_btn = QToolButton() - self.sftp_btn.setText("📁 SFTP sur cette connexion") - self.sftp_btn.setToolTip( - "Ouvre un gestionnaire de fichiers SFTP sur l'hote de la commande ssh tapee ici" - ) - self.sftp_btn.clicked.connect( - lambda: self.sftp_requested.emit(detect_ssh_target(self.terminal.buffer_text())) - ) - self.bar.addWidget(self.sftp_btn) - - self.start_connection() - - def _create_connection(self): - return LocalTerminalConnection(shell=self.shell) - - def _on_connected(self) -> None: - self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}") - super()._on_connected() - - -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("PySSH Client") - self.resize(1280, 820) - - self.session_store = SessionStore() - self.key_manager = KeyManager() - - self._build_menu() - self._build_sidebar() - self._build_tabs() - self.setStatusBar(QStatusBar()) - - self.reload_sessions() - - # demarre directement dans un terminal utilisable, sans avoir a en choisir un - self.open_local_terminal(SHELL_POWERSHELL) - - # ---------- construction UI ---------- - def _build_menu(self) -> None: - file_menu = self.menuBar().addMenu("&Fichier") - new_session_action = QAction("Nouvelle session...", self) - new_session_action.setShortcut(QKeySequence("Ctrl+N")) - new_session_action.triggered.connect(self.new_session) - file_menu.addAction(new_session_action) - - file_menu.addSeparator() - - quick_ssh_action = QAction("Connexion SSH rapide...", self) - quick_ssh_action.setShortcut(QKeySequence("Ctrl+Shift+S")) - quick_ssh_action.triggered.connect(self.quick_ssh) - file_menu.addAction(quick_ssh_action) - - quick_sftp_action = QAction("Connexion SFTP (gestionnaire de fichiers)...", self) - quick_sftp_action.setShortcut(QKeySequence("Ctrl+Shift+F")) - quick_sftp_action.triggered.connect(self.quick_sftp) - file_menu.addAction(quick_sftp_action) - - file_menu.addSeparator() - - new_ps_action = QAction("Nouveau terminal local (PowerShell)", self) - new_ps_action.setShortcut(QKeySequence("Ctrl+Shift+T")) - new_ps_action.triggered.connect(lambda: self.open_local_terminal(SHELL_POWERSHELL)) - file_menu.addAction(new_ps_action) - - new_cmd_action = QAction("Nouveau terminal local (Invite de commandes)", self) - new_cmd_action.triggered.connect(lambda: self.open_local_terminal(SHELL_CMD)) - file_menu.addAction(new_cmd_action) - - file_menu.addSeparator() - quit_action = QAction("Quitter", self) - quit_action.triggered.connect(self.close) - file_menu.addAction(quit_action) - - keys_menu = self.menuBar().addMenu("&Cles SSH") - manage_keys_action = QAction("Gestionnaire de cles...", self) - manage_keys_action.triggered.connect(self.open_key_manager) - keys_menu.addAction(manage_keys_action) - - view_menu = self.menuBar().addMenu("&Affichage") - theme_menu = view_menu.addMenu("Theme") - self.theme_group = QActionGroup(self) - self.theme_group.setExclusive(True) - for theme_id, label in THEME_LABELS.items(): - action = QAction(label, self, checkable=True) - action.setChecked(theme_id == current_theme()) - action.triggered.connect(lambda _checked=False, t=theme_id: self.change_theme(t)) - self.theme_group.addAction(action) - theme_menu.addAction(action) - - hosts_menu = self.menuBar().addMenu("&Hotes connus") - manage_hosts_action = QAction("Gestionnaire des hotes connus...", self) - manage_hosts_action.triggered.connect(self.open_known_hosts_manager) - hosts_menu.addAction(manage_hosts_action) - - help_menu = self.menuBar().addMenu("&Aide") - about_action = QAction("A propos", self) - about_action.triggered.connect(self.show_about) - help_menu.addAction(about_action) - - def _build_sidebar(self) -> None: - dock = QDockWidget("Sessions", self) - dock.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable) - - container = QWidget() - layout = QVBoxLayout(container) - layout.setContentsMargins(6, 6, 6, 6) - layout.setSpacing(6) - - toolbar = QHBoxLayout() - new_btn = QToolButton() - new_btn.setObjectName("primary") - new_btn.setText("+ Session") - new_btn.clicked.connect(self.new_session) - toolbar.addWidget(new_btn) - - term_btn = QToolButton() - term_btn.setText("💻 Terminal") - term_btn.setPopupMode(QToolButton.InstantPopup) - term_menu = QMenu(term_btn) - term_menu.addAction("PowerShell", lambda: self.open_local_terminal(SHELL_POWERSHELL)) - term_menu.addAction("Invite de commandes (cmd)", lambda: self.open_local_terminal(SHELL_CMD)) - term_btn.setMenu(term_menu) - toolbar.addWidget(term_btn) - - sftp_btn = QToolButton() - sftp_btn.setText("📁 SFTP") - sftp_btn.setToolTip("Ouvrir un gestionnaire de fichiers SFTP (sans session enregistree)") - sftp_btn.clicked.connect(self.quick_sftp) - toolbar.addWidget(sftp_btn) - - toolbar.addStretch() - - manage_btn = QToolButton() - manage_btn.setText("⚙") - manage_btn.setToolTip("Cles SSH et hotes connus") - manage_btn.setPopupMode(QToolButton.InstantPopup) - manage_menu = QMenu(manage_btn) - manage_menu.addAction("Gestionnaire de cles SSH...", self.open_key_manager) - manage_menu.addAction("Gestionnaire des hotes connus...", self.open_known_hosts_manager) - manage_btn.setMenu(manage_menu) - toolbar.addWidget(manage_btn) - - layout.addLayout(toolbar) - - self.tree = QTreeWidget() - self.tree.setHeaderHidden(True) - self.tree.itemDoubleClicked.connect(self._on_tree_double_click) - self.tree.setContextMenuPolicy(Qt.CustomContextMenu) - self.tree.customContextMenuRequested.connect(self._on_tree_context_menu) - layout.addWidget(self.tree) - - dock.setWidget(container) - self.addDockWidget(Qt.LeftDockWidgetArea, dock) - - def _build_tabs(self) -> None: - self.tabs = QTabWidget() - self.tabs.setTabsClosable(True) - self.tabs.setMovable(True) - self.tabs.tabCloseRequested.connect(self._on_tab_close) - self.setCentralWidget(self.tabs) - - # ---------- sessions / arbre ---------- - def reload_sessions(self) -> None: - self.tree.clear() - - locals_header = QTreeWidgetItem(["💻 Terminaux locaux"]) - locals_header.setFlags(locals_header.flags() & ~Qt.ItemIsSelectable) - font = locals_header.font(0) - font.setBold(True) - locals_header.setFont(0, font) - self.tree.addTopLevelItem(locals_header) - for shell, label in SHELL_LABELS.items(): - child = QTreeWidgetItem([f" {label}"]) - child.setData(0, LOCAL_SHELL_ROLE, shell) - locals_header.addChild(child) - - sessions_header = QTreeWidgetItem(["🔐 Sessions SSH"]) - sessions_header.setFlags(sessions_header.flags() & ~Qt.ItemIsSelectable) - sessions_header.setFont(0, font) - self.tree.addTopLevelItem(sessions_header) - - folder_items: dict[str, QTreeWidgetItem] = {} - - def get_folder(path: str) -> QTreeWidgetItem: - if not path: - return sessions_header - if path in folder_items: - return folder_items[path] - parent_path, _, name = path.rpartition("/") - parent_item = get_folder(parent_path) - item = QTreeWidgetItem([f"📁 {name}"]) - item.setFlags(item.flags() & ~Qt.ItemIsSelectable) - parent_item.addChild(item) - folder_items[path] = item - return item - - for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()): - parent = get_folder(session.group) - item = QTreeWidgetItem([f"🖥 {session.name} ({session.host})"]) - item.setData(0, SESSION_ROLE, session.id) - parent.addChild(item) - - if not self.session_store.sessions: - hint = QTreeWidgetItem([" (aucune session — bouton « + Session » ou « 📁 SFTP »)"]) - hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable) - hint.setForeground(0, Qt.gray) - sessions_header.addChild(hint) - - self.tree.expandAll() - - def _session_from_item(self, item: QTreeWidgetItem) -> Session | None: - if not item: - return None - session_id = item.data(0, SESSION_ROLE) - if not session_id: - return None - return next((s for s in self.session_store.sessions if s.id == session_id), None) - - def _shell_from_item(self, item: QTreeWidgetItem) -> str | None: - if not item: - return None - return item.data(0, LOCAL_SHELL_ROLE) - - def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None: - session = self._session_from_item(item) - if session: - self.open_session(session) - return - shell = self._shell_from_item(item) - if shell: - self.open_local_terminal(shell) - - def _on_tree_context_menu(self, pos) -> None: - item = self.tree.itemAt(pos) - menu = QMenu(self) - session = self._session_from_item(item) if item else None - shell = self._shell_from_item(item) if item else None - - if session: - connect_action = menu.addAction("🖥 Ouvrir un terminal SSH") - sftp_action = menu.addAction("📁 Ouvrir en SFTP (gestionnaire de fichiers)") - menu.addSeparator() - edit_action = menu.addAction("Modifier...") - duplicate_action = menu.addAction("Dupliquer") - delete_action = menu.addAction("Supprimer") - action = menu.exec(self.tree.viewport().mapToGlobal(pos)) - if action == connect_action: - self.open_session(session) - elif action == sftp_action: - self.open_sftp_session(session) - elif action == edit_action: - self.edit_session(session) - elif action == duplicate_action: - self.duplicate_session(session) - elif action == delete_action: - self.delete_session(session) - elif shell: - open_action = menu.addAction("Ouvrir") - action = menu.exec(self.tree.viewport().mapToGlobal(pos)) - if action == open_action: - self.open_local_terminal(shell) - else: - new_action = menu.addAction("Nouvelle session...") - action = menu.exec(self.tree.viewport().mapToGlobal(pos)) - if action == new_action: - self.new_session() - - def new_session(self) -> None: - dialog = SessionDialog(self.key_manager, parent=self) - if dialog.exec(): - self.session_store.add(dialog.result_session()) - self.reload_sessions() - - def edit_session(self, session: Session) -> None: - dialog = SessionDialog(self.key_manager, session=session, parent=self) - if dialog.exec(): - self.session_store.update(dialog.result_session()) - self.reload_sessions() - - def duplicate_session(self, session: Session) -> None: - new_session = copy.deepcopy(session) - new_session.id = str(uuid.uuid4()) - new_session.name = f"{session.name} (copie)" - self.session_store.add(new_session) - self.reload_sessions() - - def delete_session(self, session: Session) -> None: - if QMessageBox.question(self, "Supprimer", f"Supprimer la session '{session.name}' ?") == QMessageBox.Yes: - self.session_store.delete(session.id) - self.reload_sessions() - - # ---------- connexion / onglets ---------- - def open_session(self, session: Session) -> None: - secret = "" - if session.auth_type == AUTH_PASSWORD: - text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :") - if not ok: - return - secret = text - - tab = SessionTabWidget(session, secret) - index = self.tabs.addTab(tab, f"🖥 {session.name}") - self.tabs.setCurrentIndex(index) - - def quick_sftp(self, target: dict | None = None) -> None: - """Ouvre un SFTP en saisissant directement les infos de connexion, sans favori. - - Si l'onglet courant est un terminal ou une commande ssh a ete tapee, l'hote, - l'utilisateur et le port sont pre-remplis automatiquement.""" - if target is None: - target = self._current_tab_ssh_target() - self._quick_connect(sftp=True, target=target) - - def quick_ssh(self) -> None: - self._quick_connect(sftp=False, target=self._current_tab_ssh_target()) - - def _current_tab_ssh_target(self) -> dict | None: - widget = self.tabs.currentWidget() - if isinstance(widget, SessionTabWidget): - return { - "user": widget.session.username, - "host": widget.session.host, - "port": widget.session.port, - } - if isinstance(widget, BaseTerminalTab): - return detect_ssh_target(widget.terminal.buffer_text()) - return None - - def _quick_connect(self, sftp: bool, target: dict | None = None) -> None: - prefill = None - if target: - prefill = Session( - name=target.get("host", ""), - host=target.get("host", ""), - username=target.get("user", ""), - port=target.get("port", 22), - ) - dialog = SessionDialog( - self.key_manager, - session=prefill, - parent=self, - connect_label="Se connecter", - title="Connexion SFTP" if sftp else "Connexion SSH", - ) - if not dialog.exec(): - return - session = dialog.result_session() - if dialog.should_save(): - self.session_store.add(session) - self.reload_sessions() - if sftp: - self.open_sftp_session(session) - else: - self.open_session(session) - - def open_sftp_session(self, session: Session) -> None: - """Ouvre un onglet SFTP autonome (double panneau) pour cette session.""" - secret = "" - if session.auth_type == AUTH_PASSWORD: - text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :") - if not ok: - return - secret = text - - tab = SFTPTabWidget(session, secret) - index = self.tabs.addTab(tab, f"📁 {session.name}") - self.tabs.setCurrentIndex(index) - - def open_local_terminal(self, shell: str = SHELL_POWERSHELL) -> None: - tab = LocalTerminalTabWidget(shell) - tab.sftp_requested.connect(self.quick_sftp) - label = "PowerShell" if shell == SHELL_POWERSHELL else "cmd" - index = self.tabs.addTab(tab, f"💻 {label}") - self.tabs.setCurrentIndex(index) - - def _on_tab_close(self, index: int) -> None: - widget = self.tabs.widget(index) - if isinstance(widget, (BaseTerminalTab, SFTPTabWidget)): - widget.close_connection() - self.tabs.removeTab(index) - - # ---------- cles / a propos ---------- - def change_theme(self, theme: str) -> None: - """Applique un theme a chaud : feuille de style de l'interface + couleurs des - terminaux deja ouverts (qui ont leur propre palette, hors QSS).""" - app = QApplication.instance() - if app is None: - return - apply_theme(app, theme) - save_theme(theme) - for widget in self.findChildren(TerminalWidget): - widget.refresh_theme() - - def open_key_manager(self) -> None: - dialog = KeyManagerDialog(self.key_manager, parent=self) - dialog.exec() - - def open_known_hosts_manager(self) -> None: - dialog = KnownHostsDialog(parent=self) - dialog.exec() - - def show_about(self) -> None: - QMessageBox.information( - self, "A propos", - "PySSH Client\n\nClient SSH/SFTP local avec gestion de sessions, de cles, " - "et terminaux locaux integres.\nBase sur PySide6, paramiko, pywinpty et pyte.", - ) - - def closeEvent(self, event) -> None: - for i in range(self.tabs.count()): - widget = self.tabs.widget(i) - if isinstance(widget, (BaseTerminalTab, SFTPTabWidget)): - widget.close_connection() - super().closeEvent(event) +"""Fenetre principale : arborescence des sessions, onglets terminal/SFTP.""" + + +from __future__ import annotations + + + + + +import copy + + +import re + + +import uuid + + +from datetime import datetime + + +from pathlib import Path + + + + + +from PySide6.QtCore import Qt, Signal + + +from PySide6.QtGui import QAction, QActionGroup, QKeySequence, QShortcut + + +from PySide6.QtWidgets import ( + + + QMainWindow, QDockWidget, QTreeWidget, QTreeWidgetItem, QTabWidget, QWidget, + + + QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMenu, QMessageBox, QSplitter, + + + QStatusBar, QFrame, QApplication, QLineEdit, QFileDialog, + + +) + + + + + +from app.session_manager import SessionStore, Session, AUTH_KEY, AUTH_PASSWORD + + +from app.key_manager import KeyManager + + +from app.ssh_connection import SSHConnection + + +from app.local_terminal import ( + LocalTerminalConnection, SHELL_POWERSHELL, SHELL_CMD, SHELL_LABELS, DEFAULT_SHELL, +) + + +from app.terminal_widget import TerminalWidget + + +from app.sftp_panel import SFTPPanel + + +from app.dialogs.session_dialog import SessionDialog + + +from app.dialogs.key_dialog import KeyManagerDialog + + +from app.dialogs.known_hosts_dialog import KnownHostsDialog + + +from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change + + +from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme + + + + + +SESSION_ROLE = Qt.UserRole + 10 + + +LOCAL_SHELL_ROLE = Qt.UserRole + 11 + + + + + +# Repere une commande ssh tapee a la main dans un terminal, pour proposer le SFTP + + +# sur cet hote sans avoir a creer de favori. + + +SSH_COMMAND_RE = re.compile(r"\bssh\s+(?P[^\r\n|;&]+)") + + +# options ssh qui consomment la valeur suivante (a ne pas confondre avec l'hote) + + +SSH_VALUE_OPTIONS = { + + + "-p", "-i", "-l", "-o", "-F", "-J", "-b", "-c", "-D", "-L", "-R", "-W", "-e", "-m", "-Q", + + +} + + +HOST_RE = re.compile(r"^[A-Za-z0-9][\w.\-]*$") + + +# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles + + +ANSI_ESCAPE_RE = re.compile( + r"\x1b\[[0-9;?]*[a-zA-Z]" # CSI : couleurs, deplacements du curseur + r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC : titre de fenetre + r"|\x1b[()][A-Z0-9]" # jeux de caracteres + r"|\x1b[=>]" # mode clavier + r"|[\x00-\x08\x0b\x0c\x0e-\x1f]" # autres caracteres de controle +) + + + + + + +def _parse_ssh_args(args: str) -> dict | None: + + + tokens = args.split() + + + user, host, port = "", "", 22 + + + index = 0 + + + while index < len(tokens): + + + token = tokens[index] + + + if token.startswith("-"): + + + if token in SSH_VALUE_OPTIONS: + + + value = tokens[index + 1] if index + 1 < len(tokens) else "" + + + if token == "-p" and value.isdigit(): + + + port = int(value) + + + if token == "-l" and value: + + + user = value + + + index += 2 + + + continue + + + if token.startswith("-p") and token[2:].isdigit(): # forme collee : -p2222 + + + port = int(token[2:]) + + + index += 1 + + + continue + + + if not host: + + + candidate = token + + + if "@" in candidate: + + + user, _, candidate = candidate.partition("@") + + + if HOST_RE.match(candidate): + + + host = candidate + + + index += 1 + + + return {"user": user, "host": host, "port": port} if host else None + + + + + + + + +def detect_ssh_target(text: str) -> dict | None: + + + """Renvoie la derniere cible ssh trouvee dans le texte d'un terminal.""" + + + result = None + + + for match in SSH_COMMAND_RE.finditer(text): + + + parsed = _parse_ssh_args(match.group("args")) + + + if parsed: + + + result = parsed + + + return result + + + + + + + + +class BaseTerminalTab(QWidget): + + + """Base commune a un onglet terminal : barre de statut + terminal, autour d'une + + + connexion (SSH ou locale) qui expose la meme interface de signaux + + + (connected / data_received / disconnected / error / send / resize).""" + + + + + + def __init__(self, initial_status: str, parent=None): + + + super().__init__(parent) + + + layout = QVBoxLayout(self) + + + layout.setContentsMargins(0, 0, 0, 0) + + + layout.setSpacing(0) + + + + + + self.bar = QHBoxLayout() + + + self.bar.setContentsMargins(10, 6, 10, 6) + + + self.status_label = QLabel(initial_status) + + + self.status_label.setObjectName("statusLabel") + + + self.bar.addWidget(self.status_label, 1) + + + + + + # Apparait quand la connexion tombe : les serveurs de test redemarrent souvent, + + + # inutile de fermer l'onglet et de tout ressaisir. + + + self.reconnect_btn = QToolButton() + + + self.reconnect_btn.setObjectName("primary") + + + self.reconnect_btn.setText("⟳ Reconnecter") + + + self.reconnect_btn.hide() + + + self.reconnect_btn.clicked.connect(self.reconnect) + + + self.bar.addWidget(self.reconnect_btn) + + + + + + self.log_btn = QToolButton() + + + self.log_btn.setText("⏺ Journal") + + + self.log_btn.setToolTip("Enregistrer la session dans un fichier") + + + self.log_btn.setCheckable(True) + + + self.log_btn.clicked.connect(self.toggle_logging) + + + self.bar.addWidget(self.log_btn) + + + bar_frame = QFrame() + + + bar_frame.setObjectName("tabBar") + + + bar_frame.setLayout(self.bar) + + + layout.addWidget(bar_frame) + + + + + + self.splitter = QSplitter(Qt.Horizontal) + + + self.terminal = TerminalWidget() + + + self.splitter.addWidget(self.terminal) + + + layout.addWidget(self.splitter, 1) + + + + + + # barre de recherche, masquee tant qu'on n'a pas fait Ctrl+F + + + self.search_bar = QFrame() + + + self.search_bar.setObjectName("tabBar") + + + search_layout = QHBoxLayout(self.search_bar) + + + search_layout.setContentsMargins(10, 4, 10, 4) + + + self.search_input = QLineEdit() + + + self.search_input.setPlaceholderText("Rechercher dans l'historique... (Entree = precedent, Echap = fermer)") + + + self.search_status = QLabel("") + + + self.search_status.setObjectName("statusLabel") + + + close_search = QToolButton() + + + close_search.setText("✕") + + + search_layout.addWidget(self.search_input, 1) + + + search_layout.addWidget(self.search_status) + + + search_layout.addWidget(close_search) + + + self.search_bar.hide() + + + layout.addWidget(self.search_bar) + + + + + + self.terminal.search_requested.connect(self.open_search) + + + self.search_input.returnPressed.connect(self._run_search) + + + close_search.clicked.connect(self.close_search) + + + QShortcut(QKeySequence(Qt.Key_Escape), self.search_input, activated=self.close_search) + + + + + + self.connection = None # assigne par les sous-classes + + + self._log_file = None + + + + + + # ---------- journalisation ---------- + + + def toggle_logging(self) -> None: + + + if self._log_file: + + + self.stop_logging() + + + return + + + default = Path.home() / f"session-{datetime.now():%Y%m%d-%H%M%S}.log" + + + path, _ = QFileDialog.getSaveFileName(self, "Journaliser la session", str(default), "Journal (*.log)") + + + if not path: + + + self.log_btn.setChecked(False) + + + return + + + try: + + + self._log_file = open(path, "a", encoding="utf-8", errors="replace") + + + except OSError as exc: + + + QMessageBox.critical(self, "Erreur", f"Impossible d'ecrire le journal : {exc}") + + + self.log_btn.setChecked(False) + + + return + + + self._log_file.write(f"\n===== Session du {datetime.now():%d/%m/%Y %H:%M:%S} =====\n") + + + self.log_btn.setChecked(True) + + + self.log_btn.setToolTip(f"Journalisation active : {path}") + + + + + + def stop_logging(self) -> None: + + + if self._log_file: + + + try: + + + self._log_file.close() + + + except OSError: + + + pass + + + self._log_file = None + + + self.log_btn.setChecked(False) + + + self.log_btn.setToolTip("Enregistrer la session dans un fichier") + + + + + + def _log_output(self, data: bytes) -> None: + + + if not self._log_file: + + + return + + + # on retire les sequences d'echappement : un journal doit rester lisible + + + text = ANSI_ESCAPE_RE.sub("", data.decode("utf-8", errors="replace")) + + + try: + + + self._log_file.write(text) + + + self._log_file.flush() + + + except OSError: + + + self.stop_logging() + + + + + + def _wire_connection(self) -> None: + + + self.connection.connected.connect(self._on_connected) + + + self.connection.data_received.connect(self.terminal.feed) + + + self.connection.data_received.connect(self._log_output) + + + self.connection.disconnected.connect(self._on_disconnected) + + + self.connection.error.connect(self._on_error) + + + self.terminal.data_to_send.connect(self.connection.send) + + + self.terminal.resized.connect(self.connection.resize) + + + + + + def _create_connection(self): + + + """Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui + + + permet de la recreer a l'identique lors d'une reconnexion.""" + + + raise NotImplementedError + + + + + + def start_connection(self) -> None: + + + self.connection = self._create_connection() + + + self._wire_connection() + + + self.connection.start() + + + + + + def reconnect(self) -> None: + + + if self.connection: + + + self.connection.close_connection() + + + self.connection.wait(1500) + + + self.terminal.reset_screen() + + + self.reconnect_btn.hide() + + + self.status_label.setText("○ Reconnexion...") + + + self.start_connection() + + + + + + def _on_connected(self) -> None: + + + self.reconnect_btn.hide() + + + self.terminal.setFocus() + + + + + + def _on_disconnected(self, reason: str) -> None: + + + self.status_label.setText(f"● Deconnecte ({reason})") + + + self.reconnect_btn.show() + + + + + + def _on_error(self, message: str) -> None: + + + self.status_label.setText(f"● Erreur : {message}") + + + self.reconnect_btn.show() + + + QMessageBox.critical(self, "Erreur", message) + + + + + + # ---------- recherche dans l'historique ---------- + + + def open_search(self) -> None: + + + self.search_bar.show() + + + self.search_input.setFocus() + + + self.search_input.selectAll() + + + + + + def close_search(self) -> None: + + + self.search_bar.hide() + + + self.search_status.clear() + + + self.terminal.setFocus() + + + + + + def _run_search(self) -> None: + + + needle = self.search_input.text() + + + if not needle: + + + return + + + found = self.terminal.find_text(needle) + + + self.search_status.setText("" if found else "aucun resultat") + + + + + + def close_connection(self) -> None: + + + self.stop_logging() + + + if self.connection: + + + self.connection.close_connection() + + + self.connection.wait(2000) + + + + + + + + +class SessionTabWidget(BaseTerminalTab): + + + """Un onglet = une connexion SSH avec son terminal et un panneau SFTP optionnel.""" + + + + + + def __init__(self, session: Session, secret: str, parent=None): + + + super().__init__(f"○ Connexion a {session.username}@{session.host}...", parent) + + + self.session = session + + + self.sftp_panel: SFTPPanel | None = None + + + + + + self.sftp_toggle = QToolButton() + + + self.sftp_toggle.setText("📁 Fichiers (SFTP)") + + + self.sftp_toggle.setToolTip("Afficher / masquer le navigateur de fichiers SFTP") + + + self.sftp_toggle.setCheckable(True) + + + self.sftp_toggle.setChecked(True) + + + self.sftp_toggle.setEnabled(False) + + + self.bar.addWidget(self.sftp_toggle) + + + + + + self._secret = secret + + + self.sftp_toggle.toggled.connect(self._toggle_sftp) + + + self.start_connection() + + + + + + def _create_connection(self): + + + session = self.session + + + connection = SSHConnection( + + + host=session.host, + + + port=session.port, + + + username=session.username, + + + auth_type=session.auth_type, + + + key_path=session.key_path, + + + password=self._secret if session.auth_type == AUTH_PASSWORD else "", + + + passphrase=self._secret if session.auth_type == AUTH_KEY else "", + + + # comme MobaXterm : le navigateur de fichiers est la des l'ouverture, + + + # pas cache derriere une action supplementaire + + + open_sftp_on_connect=True, + + + ) + + + connection.sftp_ready.connect(self._on_sftp_ready) + + + connection.password_prompt.connect(self._on_password_prompt) + + + connection.host_key_prompt.connect(self._on_host_key_prompt) + + + connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt) + + + connection.passphrase_prompt.connect(self._on_passphrase_prompt) + + + return connection + + + + + + def _on_connected(self) -> None: + + + self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}") + + + # le bouton n'est active qu'une fois le canal SFTP reellement ouvert + + + # (_on_sftp_ready) : certains serveurs n'exposent pas le sous-systeme SFTP + + + super()._on_connected() + + + + + + def _on_disconnected(self, reason: str) -> None: + + + super()._on_disconnected(reason) + + + self.sftp_toggle.setEnabled(False) + + + + + + def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None: + + + accepted = confirm_host_key(self, hostname, key_type, fingerprint) + + + self.connection.confirm_host_key(accepted) + + + + + + def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None: + + + accepted = confirm_host_key_change(self, hostname, key_type, old_fp, new_fp) + + + self.connection.confirm_host_key_change(accepted) + + + + + + def _on_passphrase_prompt(self, key_path: str) -> None: + + + text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}") + + + self.connection.provide_passphrase(text if ok else None) + + + + + + def _on_password_prompt(self, target: str) -> None: + + + text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :") + + + self.connection.provide_password(text if ok else None) + + + + + + def _on_sftp_ready(self, sftp) -> None: + + + """Le canal SFTP a ete ouvert en arriere-plan : on affiche le navigateur de + + + fichiers a cote du terminal, sans action de l'utilisateur.""" + + + if self.sftp_panel is not None: + + + return + + + self.sftp_panel = SFTPPanel(sftp) + + + self.splitter.insertWidget(0, self.sftp_panel) + + + self.splitter.setSizes([460, 540]) + + + self.sftp_toggle.setEnabled(True) + + + self.sftp_toggle.setChecked(True) + + + self.terminal.setFocus() + + + + + + def _toggle_sftp(self, checked: bool) -> None: + + + if self.sftp_panel is not None: + + + self.sftp_panel.setVisible(checked) + + + + + + + + +class SFTPTabWidget(QWidget): + + + """Onglet SFTP autonome (facon WinSCP) : double panneau de fichiers, sans terminal.""" + + + + + + def __init__(self, session: Session, secret: str, parent=None): + + + super().__init__(parent) + + + self.session = session + + + self.sftp_panel: SFTPPanel | None = None + + + + + + layout = QVBoxLayout(self) + + + layout.setContentsMargins(0, 0, 0, 0) + + + layout.setSpacing(0) + + + + + + bar = QHBoxLayout() + + + bar.setContentsMargins(10, 6, 10, 6) + + + self.status_label = QLabel(f"○ Connexion SFTP a {session.username}@{session.host}...") + + + self.status_label.setObjectName("statusLabel") + + + bar.addWidget(self.status_label, 1) + + + bar_frame = QFrame() + + + bar_frame.setObjectName("tabBar") + + + bar_frame.setLayout(bar) + + + layout.addWidget(bar_frame) + + + + + + self.container = QVBoxLayout() + + + holder = QWidget() + + + holder.setLayout(self.container) + + + layout.addWidget(holder, 1) + + + + + + self.connection = SSHConnection( + + + host=session.host, + + + port=session.port, + + + username=session.username, + + + auth_type=session.auth_type, + + + key_path=session.key_path, + + + password=secret if session.auth_type == AUTH_PASSWORD else "", + + + passphrase=secret if session.auth_type == AUTH_KEY else "", + + + open_shell=False, + + + open_sftp_on_connect=True, + + + ) + + + self.connection.sftp_ready.connect(self._on_sftp_ready) + + + self.connection.password_prompt.connect(self._on_password_prompt) + + + self.connection.error.connect(self._on_error) + + + self.connection.disconnected.connect(self._on_disconnected) + + + self.connection.host_key_prompt.connect(self._on_host_key_prompt) + + + self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt) + + + self.connection.passphrase_prompt.connect(self._on_passphrase_prompt) + + + self.connection.start() + + + + + + def _on_sftp_ready(self, sftp) -> None: + + + self.sftp_panel = SFTPPanel(sftp) + + + self.container.addWidget(self.sftp_panel) + + + self.status_label.setText( + + + f"● SFTP connecte : {self.session.username}@{self.session.host}:{self.session.port}" + + + ) + + + + + + def _on_disconnected(self, reason: str) -> None: + + + self.status_label.setText(f"● Deconnecte ({reason})") + + + + + + def _on_error(self, message: str) -> None: + + + self.status_label.setText(f"● Erreur : {message}") + + + QMessageBox.critical(self, "Erreur SFTP", message) + + + + + + def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None: + + + self.connection.confirm_host_key(confirm_host_key(self, hostname, key_type, fingerprint)) + + + + + + def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None: + + + self.connection.confirm_host_key_change( + + + confirm_host_key_change(self, hostname, key_type, old_fp, new_fp) + + + ) + + + + + + def _on_passphrase_prompt(self, key_path: str) -> None: + + + text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}") + + + self.connection.provide_passphrase(text if ok else None) + + + + + + def _on_password_prompt(self, target: str) -> None: + + + text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :") + + + self.connection.provide_password(text if ok else None) + + + + + + def close_connection(self) -> None: + + + self.connection.close_connection() + + + self.connection.wait(2000) + + + + + + + + +class LocalTerminalTabWidget(BaseTerminalTab): + + + """Un onglet = un terminal Windows local (PowerShell / cmd), sans session enregistree.""" + + + + + + sftp_requested = Signal(object) # cible ssh detectee (ou None) + + + + + + def __init__(self, shell: str = SHELL_POWERSHELL, parent=None): + + + super().__init__(f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...", parent) + + + self.shell = shell + + + + + + # Meme dans un terminal local, on tape souvent "ssh user@ip" a la main : + + + # ce bouton ouvre le SFTP sur cet hote, sans passer par un favori. + + + self.sftp_btn = QToolButton() + + + self.sftp_btn.setText("📁 SFTP sur cette connexion") + + + self.sftp_btn.setToolTip( + + + "Ouvre un gestionnaire de fichiers SFTP sur l'hote de la commande ssh tapee ici" + + + ) + + + self.sftp_btn.clicked.connect( + + + lambda: self.sftp_requested.emit(detect_ssh_target(self.terminal.buffer_text())) + + + ) + + + self.bar.addWidget(self.sftp_btn) + + + + + + self.start_connection() + + + + + + def _create_connection(self): + + + return LocalTerminalConnection(shell=self.shell) + + + + + + def _on_connected(self) -> None: + + + self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}") + + + super()._on_connected() + + + + + + + + +class MainWindow(QMainWindow): + + + def __init__(self): + + + super().__init__() + + + self.setWindowTitle("PySSH Client") + + + self.resize(1280, 820) + + + + + + self.session_store = SessionStore() + + + self.key_manager = KeyManager() + + + + + + self._build_menu() + + + self._build_sidebar() + + + self._build_tabs() + + + self.setStatusBar(QStatusBar()) + + + + + + self.reload_sessions() + + + + + + # demarre directement dans un terminal utilisable, sans avoir a en choisir un + + + self.open_local_terminal(DEFAULT_SHELL) + + + + + + # ---------- construction UI ---------- + + + def _build_menu(self) -> None: + + + file_menu = self.menuBar().addMenu("&Fichier") + + + new_session_action = QAction("Nouvelle session...", self) + + + new_session_action.setShortcut(QKeySequence("Ctrl+N")) + + + new_session_action.triggered.connect(self.new_session) + + + file_menu.addAction(new_session_action) + + + + + + file_menu.addSeparator() + + + + + + quick_ssh_action = QAction("Connexion SSH rapide...", self) + + + quick_ssh_action.setShortcut(QKeySequence("Ctrl+Shift+S")) + + + quick_ssh_action.triggered.connect(self.quick_ssh) + + + file_menu.addAction(quick_ssh_action) + + + + + + quick_sftp_action = QAction("Connexion SFTP (gestionnaire de fichiers)...", self) + + + quick_sftp_action.setShortcut(QKeySequence("Ctrl+Shift+F")) + + + quick_sftp_action.triggered.connect(self.quick_sftp) + + + file_menu.addAction(quick_sftp_action) + + + + + + file_menu.addSeparator() + + + + + + default_action = QAction( + f"Nouveau terminal local ({SHELL_LABELS.get(DEFAULT_SHELL, DEFAULT_SHELL)})", self + ) + default_action.setShortcut(QKeySequence("Ctrl+Shift+T")) + default_action.triggered.connect(lambda: self.open_local_terminal(DEFAULT_SHELL)) + file_menu.addAction(default_action) + + others_menu = file_menu.addMenu("Autre terminal local") + for shell_id, label in SHELL_LABELS.items(): + others_menu.addAction(label, lambda s=shell_id: self.open_local_terminal(s)) + + + + + + file_menu.addSeparator() + + + quit_action = QAction("Quitter", self) + + + quit_action.triggered.connect(self.close) + + + file_menu.addAction(quit_action) + + + + + + keys_menu = self.menuBar().addMenu("&Cles SSH") + + + manage_keys_action = QAction("Gestionnaire de cles...", self) + + + manage_keys_action.triggered.connect(self.open_key_manager) + + + keys_menu.addAction(manage_keys_action) + + + + + + view_menu = self.menuBar().addMenu("&Affichage") + + + theme_menu = view_menu.addMenu("Theme") + + + self.theme_group = QActionGroup(self) + + + self.theme_group.setExclusive(True) + + + for theme_id, label in THEME_LABELS.items(): + + + action = QAction(label, self, checkable=True) + + + action.setChecked(theme_id == current_theme()) + + + action.triggered.connect(lambda _checked=False, t=theme_id: self.change_theme(t)) + + + self.theme_group.addAction(action) + + + theme_menu.addAction(action) + + + + + + hosts_menu = self.menuBar().addMenu("&Hotes connus") + + + manage_hosts_action = QAction("Gestionnaire des hotes connus...", self) + + + manage_hosts_action.triggered.connect(self.open_known_hosts_manager) + + + hosts_menu.addAction(manage_hosts_action) + + + + + + help_menu = self.menuBar().addMenu("&Aide") + + + about_action = QAction("A propos", self) + + + about_action.triggered.connect(self.show_about) + + + help_menu.addAction(about_action) + + + + + + def _build_sidebar(self) -> None: + + + dock = QDockWidget("Sessions", self) + + + dock.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable) + + + + + + container = QWidget() + + + layout = QVBoxLayout(container) + + + layout.setContentsMargins(6, 6, 6, 6) + + + layout.setSpacing(6) + + + + + + toolbar = QHBoxLayout() + + + new_btn = QToolButton() + + + new_btn.setObjectName("primary") + + + new_btn.setText("+ Session") + + + new_btn.clicked.connect(self.new_session) + + + toolbar.addWidget(new_btn) + + + + + + term_btn = QToolButton() + + + term_btn.setText("💻 Terminal") + + + term_btn.setPopupMode(QToolButton.InstantPopup) + + + term_menu = QMenu(term_btn) + for shell_id, label in SHELL_LABELS.items(): + term_menu.addAction(label, lambda s=shell_id: self.open_local_terminal(s)) + term_btn.setMenu(term_menu) + + + toolbar.addWidget(term_btn) + + + + + + sftp_btn = QToolButton() + + + sftp_btn.setText("📁 SFTP") + + + sftp_btn.setToolTip("Ouvrir un gestionnaire de fichiers SFTP (sans session enregistree)") + + + sftp_btn.clicked.connect(self.quick_sftp) + + + toolbar.addWidget(sftp_btn) + + + + + + toolbar.addStretch() + + + + + + manage_btn = QToolButton() + + + manage_btn.setText("⚙") + + + manage_btn.setToolTip("Cles SSH et hotes connus") + + + manage_btn.setPopupMode(QToolButton.InstantPopup) + + + manage_menu = QMenu(manage_btn) + + + manage_menu.addAction("Gestionnaire de cles SSH...", self.open_key_manager) + + + manage_menu.addAction("Gestionnaire des hotes connus...", self.open_known_hosts_manager) + + + manage_btn.setMenu(manage_menu) + + + toolbar.addWidget(manage_btn) + + + + + + layout.addLayout(toolbar) + + + + + + self.tree = QTreeWidget() + + + self.tree.setHeaderHidden(True) + + + self.tree.itemDoubleClicked.connect(self._on_tree_double_click) + + + self.tree.setContextMenuPolicy(Qt.CustomContextMenu) + + + self.tree.customContextMenuRequested.connect(self._on_tree_context_menu) + + + layout.addWidget(self.tree) + + + + + + dock.setWidget(container) + + + self.addDockWidget(Qt.LeftDockWidgetArea, dock) + + + + + + def _build_tabs(self) -> None: + + + self.tabs = QTabWidget() + + + self.tabs.setTabsClosable(True) + + + self.tabs.setMovable(True) + + + self.tabs.tabCloseRequested.connect(self._on_tab_close) + + + self.setCentralWidget(self.tabs) + + + + + + # ---------- sessions / arbre ---------- + + + def reload_sessions(self) -> None: + + + self.tree.clear() + + + + + + locals_header = QTreeWidgetItem(["💻 Terminaux locaux"]) + + + locals_header.setFlags(locals_header.flags() & ~Qt.ItemIsSelectable) + + + font = locals_header.font(0) + + + font.setBold(True) + + + locals_header.setFont(0, font) + + + self.tree.addTopLevelItem(locals_header) + + + for shell, label in SHELL_LABELS.items(): + + + child = QTreeWidgetItem([f" {label}"]) + + + child.setData(0, LOCAL_SHELL_ROLE, shell) + + + locals_header.addChild(child) + + + + + + sessions_header = QTreeWidgetItem(["🔐 Sessions SSH"]) + + + sessions_header.setFlags(sessions_header.flags() & ~Qt.ItemIsSelectable) + + + sessions_header.setFont(0, font) + + + self.tree.addTopLevelItem(sessions_header) + + + + + + folder_items: dict[str, QTreeWidgetItem] = {} + + + + + + def get_folder(path: str) -> QTreeWidgetItem: + + + if not path: + + + return sessions_header + + + if path in folder_items: + + + return folder_items[path] + + + parent_path, _, name = path.rpartition("/") + + + parent_item = get_folder(parent_path) + + + item = QTreeWidgetItem([f"📁 {name}"]) + + + item.setFlags(item.flags() & ~Qt.ItemIsSelectable) + + + parent_item.addChild(item) + + + folder_items[path] = item + + + return item + + + + + + for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()): + + + parent = get_folder(session.group) + + + item = QTreeWidgetItem([f"🖥 {session.name} ({session.host})"]) + + + item.setData(0, SESSION_ROLE, session.id) + + + parent.addChild(item) + + + + + + if not self.session_store.sessions: + + + hint = QTreeWidgetItem([" (aucune session — bouton « + Session » ou « 📁 SFTP »)"]) + + + hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable) + + + hint.setForeground(0, Qt.gray) + + + sessions_header.addChild(hint) + + + + + + self.tree.expandAll() + + + + + + def _session_from_item(self, item: QTreeWidgetItem) -> Session | None: + + + if not item: + + + return None + + + session_id = item.data(0, SESSION_ROLE) + + + if not session_id: + + + return None + + + return next((s for s in self.session_store.sessions if s.id == session_id), None) + + + + + + def _shell_from_item(self, item: QTreeWidgetItem) -> str | None: + + + if not item: + + + return None + + + return item.data(0, LOCAL_SHELL_ROLE) + + + + + + def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None: + + + session = self._session_from_item(item) + + + if session: + + + self.open_session(session) + + + return + + + shell = self._shell_from_item(item) + + + if shell: + + + self.open_local_terminal(shell) + + + + + + def _on_tree_context_menu(self, pos) -> None: + + + item = self.tree.itemAt(pos) + + + menu = QMenu(self) + + + session = self._session_from_item(item) if item else None + + + shell = self._shell_from_item(item) if item else None + + + + + + if session: + + + connect_action = menu.addAction("🖥 Ouvrir un terminal SSH") + + + sftp_action = menu.addAction("📁 Ouvrir en SFTP (gestionnaire de fichiers)") + + + menu.addSeparator() + + + edit_action = menu.addAction("Modifier...") + + + duplicate_action = menu.addAction("Dupliquer") + + + delete_action = menu.addAction("Supprimer") + + + action = menu.exec(self.tree.viewport().mapToGlobal(pos)) + + + if action == connect_action: + + + self.open_session(session) + + + elif action == sftp_action: + + + self.open_sftp_session(session) + + + elif action == edit_action: + + + self.edit_session(session) + + + elif action == duplicate_action: + + + self.duplicate_session(session) + + + elif action == delete_action: + + + self.delete_session(session) + + + elif shell: + + + open_action = menu.addAction("Ouvrir") + + + action = menu.exec(self.tree.viewport().mapToGlobal(pos)) + + + if action == open_action: + + + self.open_local_terminal(shell) + + + else: + + + new_action = menu.addAction("Nouvelle session...") + + + action = menu.exec(self.tree.viewport().mapToGlobal(pos)) + + + if action == new_action: + + + self.new_session() + + + + + + def new_session(self) -> None: + + + dialog = SessionDialog(self.key_manager, parent=self) + + + if dialog.exec(): + + + self.session_store.add(dialog.result_session()) + + + self.reload_sessions() + + + + + + def edit_session(self, session: Session) -> None: + + + dialog = SessionDialog(self.key_manager, session=session, parent=self) + + + if dialog.exec(): + + + self.session_store.update(dialog.result_session()) + + + self.reload_sessions() + + + + + + def duplicate_session(self, session: Session) -> None: + + + new_session = copy.deepcopy(session) + + + new_session.id = str(uuid.uuid4()) + + + new_session.name = f"{session.name} (copie)" + + + self.session_store.add(new_session) + + + self.reload_sessions() + + + + + + def delete_session(self, session: Session) -> None: + + + if QMessageBox.question(self, "Supprimer", f"Supprimer la session '{session.name}' ?") == QMessageBox.Yes: + + + self.session_store.delete(session.id) + + + self.reload_sessions() + + + + + + # ---------- connexion / onglets ---------- + + + def open_session(self, session: Session) -> None: + + + secret = "" + + + if session.auth_type == AUTH_PASSWORD: + + + text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :") + + + if not ok: + + + return + + + secret = text + + + + + + tab = SessionTabWidget(session, secret) + + + index = self.tabs.addTab(tab, f"🖥 {session.name}") + + + self.tabs.setCurrentIndex(index) + + + + + + def quick_sftp(self, target: dict | None = None) -> None: + + + """Ouvre un SFTP en saisissant directement les infos de connexion, sans favori. + + + + + + Si l'onglet courant est un terminal ou une commande ssh a ete tapee, l'hote, + + + l'utilisateur et le port sont pre-remplis automatiquement.""" + + + if target is None: + + + target = self._current_tab_ssh_target() + + + self._quick_connect(sftp=True, target=target) + + + + + + def quick_ssh(self) -> None: + + + self._quick_connect(sftp=False, target=self._current_tab_ssh_target()) + + + + + + def _current_tab_ssh_target(self) -> dict | None: + + + widget = self.tabs.currentWidget() + + + if isinstance(widget, SessionTabWidget): + + + return { + + + "user": widget.session.username, + + + "host": widget.session.host, + + + "port": widget.session.port, + + + } + + + if isinstance(widget, BaseTerminalTab): + + + return detect_ssh_target(widget.terminal.buffer_text()) + + + return None + + + + + + def _quick_connect(self, sftp: bool, target: dict | None = None) -> None: + + + prefill = None + + + if target: + + + prefill = Session( + + + name=target.get("host", ""), + + + host=target.get("host", ""), + + + username=target.get("user", ""), + + + port=target.get("port", 22), + + + ) + + + dialog = SessionDialog( + + + self.key_manager, + + + session=prefill, + + + parent=self, + + + connect_label="Se connecter", + + + title="Connexion SFTP" if sftp else "Connexion SSH", + + + ) + + + if not dialog.exec(): + + + return + + + session = dialog.result_session() + + + if dialog.should_save(): + + + self.session_store.add(session) + + + self.reload_sessions() + + + if sftp: + + + self.open_sftp_session(session) + + + else: + + + self.open_session(session) + + + + + + def open_sftp_session(self, session: Session) -> None: + + + """Ouvre un onglet SFTP autonome (double panneau) pour cette session.""" + + + secret = "" + + + if session.auth_type == AUTH_PASSWORD: + + + text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :") + + + if not ok: + + + return + + + secret = text + + + + + + tab = SFTPTabWidget(session, secret) + + + index = self.tabs.addTab(tab, f"📁 {session.name}") + + + self.tabs.setCurrentIndex(index) + + + + + + def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None: + + + tab = LocalTerminalTabWidget(shell) + + + tab.sftp_requested.connect(self.quick_sftp) + + + label = SHELL_LABELS.get(shell, shell) + + + index = self.tabs.addTab(tab, f"💻 {label}") + + + self.tabs.setCurrentIndex(index) + + + + + + def _on_tab_close(self, index: int) -> None: + + + widget = self.tabs.widget(index) + + + if isinstance(widget, (BaseTerminalTab, SFTPTabWidget)): + + + widget.close_connection() + + + self.tabs.removeTab(index) + + + + + + # ---------- cles / a propos ---------- + + + def change_theme(self, theme: str) -> None: + + + """Applique un theme a chaud : feuille de style de l'interface + couleurs des + + + terminaux deja ouverts (qui ont leur propre palette, hors QSS).""" + + + app = QApplication.instance() + + + if app is None: + + + return + + + apply_theme(app, theme) + + + save_theme(theme) + + + for widget in self.findChildren(TerminalWidget): + + + widget.refresh_theme() + + + + + + def open_key_manager(self) -> None: + + + dialog = KeyManagerDialog(self.key_manager, parent=self) + + + dialog.exec() + + + + + + def open_known_hosts_manager(self) -> None: + + + dialog = KnownHostsDialog(parent=self) + + + dialog.exec() + + + + + + def show_about(self) -> None: + + + QMessageBox.information( + + + self, "A propos", + + + "PySSH Client\n\nClient SSH/SFTP local avec gestion de sessions, de cles, " + + + "et terminaux locaux integres.\nBase sur PySide6, paramiko, pywinpty et pyte.", + + + ) + + + + + + def closeEvent(self, event) -> None: + + + for i in range(self.tabs.count()): + + + widget = self.tabs.widget(i) + + + if isinstance(widget, (BaseTerminalTab, SFTPTabWidget)): + + + widget.close_connection() + + + super().closeEvent(event) + +