"""Cycle de vie des onglets (terminal SSH, terminal local, SFTP autonome) : ouverture, connexion rapide, renommage, fermeture.""" from __future__ import annotations from PySide6.QtCore import Qt, Signal, QObject from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog from app.session_manager import SessionStore, Session, AUTH_PASSWORD, PASSWORD_STORAGE_SECURE from app.key_manager import KeyManager from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS from app.dialogs.session_dialog import SessionDialog from app.dialogs.connect_prompt import ask_secret from app.services.ssh_target import detect_ssh_target from app.openssh.config import OpenSSHHost, session_from_openssh_host from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError from app.credentials import CredentialStore, NullCredentialStore, session_credential_key from app.controllers.broadcast_manager import BroadcastManager from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget from app.ui.split_pane import SplitContainer, PaneSlot, LAYOUT_LABELS class ConnectionController(QObject): """Possede le QTabWidget central. `session_saved` est emis quand une connexion rapide est aussi enregistree comme session, pour que le panneau lateral se rafraichisse sans que ce controleur ait besoin de le connaitre.""" session_saved = Signal() def __init__( self, session_store: SessionStore, key_manager: KeyManager, broadcast: BroadcastManager, snippet_store=None, credential_store: CredentialStore | None = None, ai_provider_factory=None, parent_widget: QWidget | None = None, ): super().__init__(parent_widget) self.session_store = session_store self.key_manager = key_manager self.broadcast = broadcast self.snippet_store = snippet_store self.credential_store = credential_store or NullCredentialStore() self.ai_provider_factory = ai_provider_factory self._parent_widget = parent_widget self.tabs = QTabWidget(parent_widget) self.tabs.setTabsClosable(True) self.tabs.setMovable(True) self.tabs.tabBarDoubleClicked.connect(self.rename_tab) self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu) self.tabs.tabBar().customContextMenuRequested.connect(self._on_tab_context_menu) self.tabs.tabCloseRequested.connect(self._on_tab_close) # ---------- ouverture ---------- def _resolve_bastion(self, session: Session) -> tuple[bool, BastionTarget | None, str]: """Renvoie (ok, bastion_target, bastion_password). ok=False signifie que l'ouverture doit etre annulee (bastion invalide, ou mot de passe du bastion non fourni) ; l'appelant ne doit alors rien ouvrir.""" try: target = resolve_bastion_target(session.bastion, self.session_store) except BastionResolutionError as exc: QMessageBox.critical(self._parent_widget, "Bastion invalide", str(exc)) return False, None, "" if target is None: return True, None, "" if target.auth_type != AUTH_PASSWORD: return True, target, "" # Un bastion "session PySSH" reutilise un mot de passe deja memorise pour # cette session (s'il en existe un) : jamais ecrit depuis ce chemin, on ne # fait que lire ce que l'ouverture directe de cette session aurait memorise. if target.credential_key: stored = self.credential_store.get_secret(target.credential_key) if stored is not None: return True, target, stored text, ok = ask_secret( self._parent_widget, "Mot de passe du bastion", f"Mot de passe pour {target.label} (bastion) :", ) if not ok: return False, None, "" return True, target, text def _ask_session_password(self, session: Session) -> tuple[bool, str]: if session.auth_type != AUTH_PASSWORD: return True, "" remember = session.password_storage == PASSWORD_STORAGE_SECURE key = session_credential_key(session.id) if remember: stored = self.credential_store.get_secret(key) if stored is not None: return True, stored text, ok = ask_secret( self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :" ) if ok and remember: self.credential_store.set_secret(key, text) return ok, (text if ok else "") def build_session_tab(self, session: Session) -> SessionTabWidget | None: """Construit l'onglet sans l'inserer dans le QTabWidget (reutilise par l'ouverture normale et par la restauration d'un workspace dans un panneau). None si l'utilisateur annule (mot de passe, bastion invalide).""" ok, bastion_target, bastion_password = self._resolve_bastion(session) if not ok: return None ok, secret = self._ask_session_password(session) if not ok: return None return SessionTabWidget( session, secret, bastion_target=bastion_target, bastion_password=bastion_password, snippet_store=self.snippet_store, broadcast=self.broadcast, ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store, ) def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None: ok, bastion_target, bastion_password = self._resolve_bastion(session) if not ok: return None ok, secret = self._ask_session_password(session) if not ok: return None return SFTPTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password) def open_session(self, session: Session) -> None: tab = self.build_session_tab(session) if tab is None: return self.broadcast.register(tab) index = self.tabs.addTab(tab, f"🖥 {session.name}") self.tabs.setCurrentIndex(index) def open_sftp_session(self, session: Session) -> None: """Ouvre un onglet SFTP autonome (double panneau) pour cette session.""" tab = self.build_sftp_tab(session) if tab is None: return index = self.tabs.addTab(tab, f"📁 {session.name}") self.tabs.setCurrentIndex(index) def open_openssh_host(self, host: OpenSSHHost) -> None: """Connexion directe a un Host de ~/.ssh/config, sans l'enregistrer comme session. Un ProxyJump eventuel est resolu et applique via app.bastion.""" self.open_session(session_from_openssh_host(host)) def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None: tab = self.new_local_terminal(shell) label = SHELL_LABELS.get(shell, shell) index = self.tabs.addTab(tab, f"💻 {label}") self.tabs.setCurrentIndex(index) def new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget: """Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter au QTabWidget : reutilise pour un onglet normal comme pour un panneau.""" tab = LocalTerminalTabWidget( shell, snippet_store=self.snippet_store, broadcast=self.broadcast, ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store, ) tab.sftp_requested.connect(self.quick_sftp) self.broadcast.register(tab) return tab 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._parent_widget, connect_label="Se connecter", title="Connexion SFTP" if sftp else "Connexion SSH", session_store=self.session_store, credential_store=self.credential_store, ) if not dialog.exec(): return session = dialog.result_session() if dialog.should_save(): self.session_store.add(session) self.session_saved.emit() if sftp: self.open_sftp_session(session) else: self.open_session(session) # ---------- gestion des onglets ---------- def rename_tab(self, index: int) -> None: if index < 0: return actuel = self.tabs.tabText(index) nouveau, ok = QInputDialog.getText( self._parent_widget, "Renommer l'onglet", "Nom de l'onglet :", text=actuel ) if ok and nouveau.strip(): self.tabs.setTabText(index, nouveau.strip()) def _on_tab_context_menu(self, pos) -> None: bar = self.tabs.tabBar() index = bar.tabAt(pos) if index < 0: return widget = self.tabs.widget(index) menu = QMenu(self._parent_widget) act_rename = menu.addAction("Renommer... (double-clic)") menu.addSeparator() # Diviser : seulement pour un terminal simple (SSH ou local), pas pour # un SFTP ni pour un split existant (pas d'imbrication de splits). split_actions = {} if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)): split_menu = menu.addMenu("Diviser") for kind, label in LAYOUT_LABELS.items(): split_actions[split_menu.addAction(label)] = kind menu.addSeparator() act_close = menu.addAction("Fermer cet onglet") act_close_others = menu.addAction("Fermer les autres onglets") act_close_others.setEnabled(self.tabs.count() > 1) action = menu.exec(bar.mapToGlobal(pos)) if action == act_rename: self.rename_tab(index) elif action == act_close: self._on_tab_close(index) elif action == act_close_others: # on ferme par la fin : retirer un onglet decale les suivants cible = self.tabs.widget(index) for other in range(self.tabs.count() - 1, -1, -1): if self.tabs.widget(other) is not cible: self._on_tab_close(other) elif action in split_actions: self.split_tab(index, split_actions[action]) def _on_tab_close(self, index: int) -> None: widget = self.tabs.widget(index) if isinstance(widget, SplitContainer): widget.close_all() elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)): widget.close_connection() self.tabs.removeTab(index) def close_all(self) -> None: """A appeler a la fermeture de l'application : ferme proprement toutes les connexions (SSH, locales, SFTP) avant que les widgets ne soient detruits.""" for i in range(self.tabs.count()): widget = self.tabs.widget(i) if isinstance(widget, SplitContainer): widget.close_all() elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)): widget.close_connection() # ---------- split terminal ---------- def split_tab(self, index: int, layout_kind: str) -> None: """Transforme l'onglet en une grille de terminaux : le contenu actuel occupe le premier panneau, les autres recoivent un terminal local par defaut (remplacable ensuite via le menu ☰ de chaque panneau).""" widget = self.tabs.widget(index) if not isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)): return title = self.tabs.tabText(index) # SplitContainer reparente `widget` dans un de ses panneaux ; Qt retire # alors tout seul l'onglet correspondant (un widget ne peut avoir qu'un # parent), en decalant les onglets suivants - inutile donc d'appeler # removeTab(index) explicitement, ce qui viserait desormais un autre onglet. container = SplitContainer(widget, layout_kind, self.new_local_terminal) container.all_panes_closed.connect( lambda remaining, c=container: self._on_split_collapsed(c, remaining) ) container.pane_menu_requested.connect(self._show_pane_menu) self.tabs.insertTab(index, container, f"⊞ {title}") self.tabs.setCurrentIndex(index) def _on_split_collapsed(self, container: SplitContainer, remaining: QWidget | None) -> None: """Il ne reste plus qu'un panneau : on redeplie vers un onglet normal.""" index = self.tabs.indexOf(container) if index < 0: return self.tabs.removeTab(index) if remaining is None: return title = self.default_title_for(remaining) self.tabs.insertTab(index, remaining, title) self.tabs.setCurrentIndex(index) @staticmethod def default_title_for(widget: QWidget) -> str: if isinstance(widget, SessionTabWidget): return f"🖥 {widget.session.name}" if isinstance(widget, SFTPTabWidget): return f"📁 {widget.session.name}" if isinstance(widget, LocalTerminalTabWidget): return f"💻 {SHELL_LABELS.get(widget.shell, widget.shell)}" return "Onglet" def _movable_tabs(self) -> list[tuple[int, str, QWidget]]: """Onglets de premier niveau deplacables vers un panneau (ni SFTP, ni un split - on ne gere pas les deplacements panneau-a-panneau).""" result = [] for i in range(self.tabs.count()): widget = self.tabs.widget(i) if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)): result.append((i, self.tabs.tabText(i), widget)) return result def _show_pane_menu(self, pane: PaneSlot, container: SplitContainer) -> None: menu = QMenu(self._parent_widget) term_menu = menu.addMenu("Terminal local") shell_actions = {} for shell_id, label in SHELL_LABELS.items(): shell_actions[term_menu.addAction(label)] = shell_id movable = self._movable_tabs() move_actions = {} if movable: menu.addSeparator() move_menu = menu.addMenu("Deplacer un onglet ici") for index, title, widget in movable: move_actions[move_menu.addAction(title)] = (index, widget) else: hint = menu.addAction("(aucun autre onglet a deplacer ici)") hint.setEnabled(False) action = menu.exec(pane.menu_btn.mapToGlobal(pane.menu_btn.rect().bottomLeft())) if action in shell_actions: container.replace_pane_content(pane, self.new_local_terminal(shell_actions[action])) elif action in move_actions: index, moved_widget = move_actions[action] self.move_tab_into_pane(index, container, pane) def move_tab_into_pane(self, tab_index: int, container: SplitContainer, pane: PaneSlot) -> None: """Deplace l'onglet de premier niveau `tab_index` dans `pane` : sa connexion continue de vivre, seul son emplacement visuel change.""" widget = self.tabs.widget(tab_index) self.tabs.removeTab(tab_index) # ne ferme pas la connexion : elle continue dans le panneau container.replace_pane_content(pane, widget)