"""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()