"""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.tunnel_dialog import TunnelDialog from app.tunnels import TunnelManager 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.tunnel_btn = QToolButton() self.tunnel_btn.setText("🔀 Tunnels") self.tunnel_btn.setToolTip("Redirections de ports SSH (-L / -R)") self.tunnel_btn.setEnabled(False) self.tunnel_btn.clicked.connect(self.open_tunnels) self.bar.addWidget(self.tunnel_btn) self.tunnel_manager: TunnelManager | None = None 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 open_tunnels(self) -> None: if not self.tunnel_manager: QMessageBox.information(self, "Tunnels", "La session doit etre connectee.") return TunnelDialog(self.tunnel_manager, parent=self).exec() actifs = len(self.tunnel_manager.tunnels) self.tunnel_btn.setText(f"🔀 Tunnels ({actifs})" if actifs else "🔀 Tunnels") def _on_connected(self) -> None: self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}") transport = self.connection.client.get_transport() if self.connection.client else None if transport: self.tunnel_manager = TunnelManager(transport) self.tunnel_btn.setEnabled(True) # 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 close_connection(self) -> None: if self.tunnel_manager: self.tunnel_manager.stop_all() self.tunnel_manager = None super().close_connection() 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") self.toggle_sidebar_action = QAction("Panneau des sessions", self, checkable=True) self.toggle_sidebar_action.setChecked(True) self.toggle_sidebar_action.setShortcut(QKeySequence("F9")) self.toggle_sidebar_action.triggered.connect(self.toggle_sidebar) view_menu.addAction(self.toggle_sidebar_action) view_menu.addSeparator() 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) # Closable : sans cette option, le panneau n'a pas de bouton de fermeture # et ne peut pas etre masque. dock.setFeatures( QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetClosable ) self.sessions_dock = dock 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) self.broadcast_btn = QToolButton() self.broadcast_btn.setText("⇉ Multi") self.broadcast_btn.setCheckable(True) self.broadcast_btn.setToolTip( "Envoi simultane : ce que vous tapez part vers tous les onglets terminal ouverts" ) toolbar.addWidget(self.broadcast_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) # Sans minimum explicite, la largeur plancher est imposee par le contenu # (les noms de sessions), ce qui empeche de retrecir vraiment le panneau. container.setMinimumWidth(120) self.tree.setMinimumWidth(0) dock.setWidget(container) self.addDockWidget(Qt.LeftDockWidgetArea, dock) self.resizeDocks([dock], [300], Qt.Horizontal) # garde la case du menu Affichage synchronisee si on ferme le panneau par sa croix dock.visibilityChanged.connect(self.toggle_sidebar_action.setChecked) 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) self._register_broadcast(tab) 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) self._register_broadcast(tab) label = SHELL_LABELS.get(shell, shell) index = self.tabs.addTab(tab, f"💻 {label}") self.tabs.setCurrentIndex(index) def _broadcast(self, data: bytes, source) -> None: """Envoie la meme frappe aux autres onglets terminal quand le mode Multi est actif.""" if not self.broadcast_btn.isChecked(): return for index in range(self.tabs.count()): tab = self.tabs.widget(index) if tab is source or not isinstance(tab, BaseTerminalTab): continue if tab.connection: # on ecrit directement dans la connexion : passer par le terminal # relancerait la diffusion et boucherait a l'infini tab.connection.send(data) def _register_broadcast(self, tab) -> None: tab.terminal.data_to_send.connect(lambda data, t=tab: self._broadcast(data, t)) 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 toggle_sidebar(self, visible: bool | None = None) -> None: """Affiche ou masque le panneau des sessions (F9).""" if visible is None: visible = not self.sessions_dock.isVisible() self.sessions_dock.setVisible(visible) self.toggle_sidebar_action.setChecked(visible) 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)