Files
pyssh-client/app/controllers/session_controller.py
Florian c08037a638 feat: ajouter la prise en charge de ~/.ssh/config
Support minimum : Host, HostName, User, Port, IdentityFile, ProxyJump,
ForwardAgent, ServerAliveInterval, ServerAliveCountMax.

- app/openssh/config.py : load_openssh_hosts() lit ~/.ssh/config via
  paramiko.SSHConfig (gere nativement les blocs "Host *" fusionnes, les alias
  multiples sur une meme ligne Host, et l'expansion ~ des IdentityFile).
  Fichier absent = liste vide, pas une erreur. session_from_openssh_host()
  convertit un Host en Session PySSH (groupe "OpenSSH", cle si IdentityFile
  presente sinon authentification auto).
- Nouvelle section "Configuration OpenSSH" dans le panneau lateral
  (SessionController), sous Sessions SSH : double-clic ou menu contextuel pour
  se connecter directement ou importer comme session (editeur pre-rempli,
  ajustable avant enregistrement) ; rafraichissement via le menu ⚙ ou le menu
  contextuel de l'hote.
- ConnectionController.open_openssh_host() ouvre une connexion directe sans
  enregistrer de session, comme une connexion rapide.

Le ProxyJump est detecte, affiche (badge "· via <bastion>", tooltip) et reporte
dans les notes d'une session importee, mais pas encore applique automatiquement
a la connexion : ce sera l'objet de la Phase 3 (Bastion / Jump Host), qui
prendra en charge toutes ses sources (OpenSSH, session PySSH, config manuelle)
via un mecanisme commun.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:49:59 +02:00

303 lines
12 KiB
Python

"""Panneau lateral : arbre des sessions SSH enregistrees + terminaux locaux, et
leur CRUD (creer / modifier / dupliquer / supprimer). N'ouvre aucune connexion
elle-meme : elle emet des signaux que ConnectionController ecoute."""
from __future__ import annotations
import copy
import uuid
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QTreeWidget, QTreeWidgetItem,
QMenu, QMessageBox,
)
from app.session_manager import SessionStore, Session
from app.key_manager import KeyManager
from app.local_terminal import SHELL_LABELS
from app.dialogs.session_dialog import SessionDialog
from app.openssh.config import (
OpenSSHHost, default_config_path, load_openssh_hosts, session_from_openssh_host,
)
SESSION_ROLE = Qt.UserRole + 10
LOCAL_SHELL_ROLE = Qt.UserRole + 11
OPENSSH_HOST_ROLE = Qt.UserRole + 12
class SessionController(QObject):
open_session_requested = Signal(object) # Session
open_sftp_requested = Signal(object) # Session
open_local_shell_requested = Signal(str) # identifiant de shell
open_openssh_host_requested = Signal(object) # OpenSSHHost
quick_sftp_requested = Signal()
manage_keys_requested = Signal()
manage_known_hosts_requested = Signal()
def __init__(
self,
session_store: SessionStore,
key_manager: KeyManager,
broadcast_button: QToolButton,
parent_widget: QWidget | None = None,
openssh_config_path=None,
):
super().__init__(parent_widget)
self.session_store = session_store
self.key_manager = key_manager
self._parent_widget = parent_widget
self._openssh_config_path = openssh_config_path or default_config_path()
self.widget = QWidget(parent_widget)
layout = QVBoxLayout(self.widget)
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_shell_requested.emit(s))
term_btn.setMenu(term_menu)
toolbar.addWidget(term_btn)
toolbar.addWidget(broadcast_button)
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_requested.emit)
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.manage_keys_requested.emit)
manage_menu.addAction("Gestionnaire des hotes connus...", self.manage_known_hosts_requested.emit)
manage_menu.addSeparator()
manage_menu.addAction("Rafraichir la configuration OpenSSH (~/.ssh/config)", self.reload)
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.
self.widget.setMinimumWidth(120)
self.tree.setMinimumWidth(0)
self.reload()
# ---------- arbre ----------
def reload(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)
openssh_header = QTreeWidgetItem(["⚙ Configuration OpenSSH"])
openssh_header.setFlags(openssh_header.flags() & ~Qt.ItemIsSelectable)
openssh_header.setFont(0, font)
openssh_header.setToolTip(0, str(self._openssh_config_path))
self.tree.addTopLevelItem(openssh_header)
hosts = load_openssh_hosts(self._openssh_config_path)
for host in hosts:
label = f"🖥 {host.alias} ({host.display_target})"
if host.proxy_jump:
label += f" · via {host.proxy_jump}"
item = QTreeWidgetItem([label])
item.setData(0, OPENSSH_HOST_ROLE, host)
if host.proxy_jump:
item.setToolTip(
0,
f"ProxyJump : {host.proxy_jump} (pas encore applique automatiquement, "
"connexion directe pour l'instant)",
)
openssh_header.addChild(item)
if not hosts:
hint = QTreeWidgetItem([f" (aucun host trouve dans {self._openssh_config_path})"])
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
hint.setForeground(0, Qt.gray)
openssh_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 _openssh_host_from_item(self, item: QTreeWidgetItem) -> OpenSSHHost | None:
if not item:
return None
return item.data(0, OPENSSH_HOST_ROLE)
def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None:
session = self._session_from_item(item)
if session:
self.open_session_requested.emit(session)
return
shell = self._shell_from_item(item)
if shell:
self.open_local_shell_requested.emit(shell)
return
host = self._openssh_host_from_item(item)
if host:
self.open_openssh_host_requested.emit(host)
def _on_tree_context_menu(self, pos) -> None:
item = self.tree.itemAt(pos)
menu = QMenu(self.widget)
session = self._session_from_item(item) if item else None
shell = self._shell_from_item(item) if item else None
openssh_host = self._openssh_host_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_requested.emit(session)
elif action == sftp_action:
self.open_sftp_requested.emit(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_shell_requested.emit(shell)
elif openssh_host:
connect_action = menu.addAction("🖥 Se connecter")
import_action = menu.addAction("Importer comme session PySSH...")
menu.addSeparator()
refresh_action = menu.addAction("🔄 Rafraichir la configuration")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == connect_action:
self.open_openssh_host_requested.emit(openssh_host)
elif action == import_action:
self.import_openssh_host(openssh_host)
elif action == refresh_action:
self.reload()
else:
new_action = menu.addAction("Nouvelle session...")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == new_action:
self.new_session()
# ---------- CRUD ----------
def new_session(self) -> None:
dialog = SessionDialog(self.key_manager, parent=self._parent_widget)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()
def edit_session(self, session: Session) -> None:
dialog = SessionDialog(self.key_manager, session=session, parent=self._parent_widget)
if dialog.exec():
self.session_store.update(dialog.result_session())
self.reload()
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()
def delete_session(self, session: Session) -> None:
if QMessageBox.question(
self._parent_widget, "Supprimer", f"Supprimer la session '{session.name}' ?"
) == QMessageBox.Yes:
self.session_store.delete(session.id)
self.reload()
# ---------- OpenSSH config ----------
def import_openssh_host(self, host: OpenSSHHost) -> None:
"""Ouvre l'editeur de session pre-rempli depuis ce Host, pour permettre
d'ajuster avant d'enregistrer (comme une connexion rapide)."""
dialog = SessionDialog(
self.key_manager, session=session_from_openssh_host(host), parent=self._parent_widget,
title=f"Importer « {host.alias} » comme session",
)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()