"""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 QObject, Qt, Signal from PySide6.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, QMenu, QMessageBox, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from app.credentials import CredentialStore, NullCredentialStore, session_credential_key from app.dialogs.group_settings_dialog import GroupSettingsDialog from app.dialogs.session_dialog import SessionDialog from app.groups import GroupSettingsStore from app.key_manager import KeyManager from app.local_terminal import SHELL_LABELS from app.openssh.config import ( OpenSSHHost, default_config_path, load_openssh_hosts, session_from_openssh_host, ) from app.session_manager import Session, SessionStore SESSION_ROLE = Qt.UserRole + 10 LOCAL_SHELL_ROLE = Qt.UserRole + 11 OPENSSH_HOST_ROLE = Qt.UserRole + 12 GROUP_PATH_ROLE = Qt.UserRole + 13 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, group_store: GroupSettingsStore | None = None, credential_store: CredentialStore | None = None, ): super().__init__(parent_widget) self.session_store = session_store self.key_manager = key_manager self.group_store = group_store self.credential_store = credential_store or NullCredentialStore() 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(lambda: 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) filter_row = QHBoxLayout() filter_row.addWidget(QLabel("๐Ÿท")) self.tag_filter_combo = QComboBox() self.tag_filter_combo.setEditable(True) self.tag_filter_combo.setInsertPolicy(QComboBox.NoInsert) self.tag_filter_combo.lineEdit().setPlaceholderText("Filtrer les sessions par tag...") self.tag_filter_combo.currentTextChanged.connect(self._apply_tag_filter) filter_row.addWidget(self.tag_filter_combo, 1) layout.addLayout(filter_row) 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) item.setData(0, GROUP_PATH_ROLE, path) 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) label = f"๐Ÿ–ฅ {session.name} ({session.host})" if session.tags: label += " [" + ", ".join(session.tags) + "]" item = QTreeWidgetItem([label]) 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 : via {host.proxy_jump}") 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() self._reload_tag_filter() self._apply_tag_filter() def _reload_tag_filter(self) -> None: current = self.tag_filter_combo.currentText() self.tag_filter_combo.blockSignals(True) self.tag_filter_combo.clear() self.tag_filter_combo.addItem("") # aucun filtre self.tag_filter_combo.addItems(self.session_store.tags()) self.tag_filter_combo.setCurrentText(current) self.tag_filter_combo.blockSignals(False) def _apply_tag_filter(self, _text: str = "") -> None: query = self.tag_filter_combo.currentText().strip().lower() def visit(item: QTreeWidgetItem) -> None: session = self._session_from_item(item) if session is not None: item.setHidden(bool(query) and not any(query in t.lower() for t in session.tags)) for i in range(item.childCount()): visit(item.child(i)) for i in range(self.tree.topLevelItemCount()): visit(self.tree.topLevelItem(i)) 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 self.session_store.get(session_id) 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 _group_from_item(self, item: QTreeWidgetItem) -> str | None: if not item: return None return item.data(0, GROUP_PATH_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 group_path = self._group_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() elif group_path: new_in_group_action = menu.addAction("Nouvelle session dans ce dossier...") menu.addSeparator() group_settings_action = menu.addAction("Parametres du groupe...") action = menu.exec(self.tree.viewport().mapToGlobal(pos)) if action == new_in_group_action: self.new_session(group=group_path) elif action == group_settings_action: self.open_group_settings(group_path) 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, group: str = "") -> None: prefill = Session(group=group) if group else None dialog = SessionDialog( self.key_manager, session=prefill, parent=self._parent_widget, session_store=self.session_store, group_store=self.group_store, credential_store=self.credential_store, ) 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, session_store=self.session_store, group_store=self.group_store, credential_store=self.credential_store, ) 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.credential_store.delete_secret(session_credential_key(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", session_store=self.session_store, group_store=self.group_store, credential_store=self.credential_store, ) if dialog.exec(): self.session_store.add(dialog.result_session()) self.reload() # ---------- parametres de groupe ---------- def open_group_settings(self, group_path: str) -> None: if self.group_store is None: return dialog = GroupSettingsDialog( group_path, self.group_store, self.key_manager, self.session_store, parent=self._parent_widget, ) dialog.exec()