diff --git a/app/controllers/connection_controller.py b/app/controllers/connection_controller.py index 1fc1f82..5e0032c 100644 --- a/app/controllers/connection_controller.py +++ b/app/controllers/connection_controller.py @@ -15,6 +15,7 @@ from app.openssh.config import OpenSSHHost, session_from_openssh_host from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError 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): @@ -108,13 +109,19 @@ class ConnectionController(QObject): self.open_session(session_from_openssh_host(host)) def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None: - tab = LocalTerminalTabWidget(shell) - tab.sftp_requested.connect(self.quick_sftp) - self.broadcast.register(tab) + 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) + 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. @@ -183,9 +190,20 @@ class ConnectionController(QObject): 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) @@ -201,10 +219,14 @@ class ConnectionController(QObject): 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, (BaseTerminalTab, SFTPTabWidget)): + if isinstance(widget, SplitContainer): + widget.close_all() + elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)): widget.close_connection() self.tabs.removeTab(index) @@ -213,5 +235,92 @@ class ConnectionController(QObject): 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, (BaseTerminalTab, SFTPTabWidget)): + 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) diff --git a/app/main_window.py b/app/main_window.py index f71e93e..78e685e 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -22,6 +22,7 @@ from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme from app.controllers.broadcast_manager import BroadcastManager from app.controllers.session_controller import SessionController from app.controllers.connection_controller import ConnectionController +from app.ui.split_pane import SplitContainer # re-exporte pour compatibilite (utilise par tests/test_detect.py et du code existant) from app.services.ssh_target import detect_ssh_target # noqa: F401 @@ -59,8 +60,17 @@ class MainWindow(QMainWindow): return self.connection_controller.tabs def _iter_tabs(self): + """Widgets de terminal joignables par le Multi-Send : les onglets de + premier niveau, et les panneaux a l'interieur des onglets divises.""" tabs = self.connection_controller.tabs - return [tabs.widget(i) for i in range(tabs.count())] + result = [] + for i in range(tabs.count()): + widget = tabs.widget(i) + if isinstance(widget, SplitContainer): + result.extend(pane.content for pane in widget.panes) + else: + result.append(widget) + return result # ---------- cablage entre controleurs ---------- def _wire_controllers(self) -> None: diff --git a/app/ui/split_pane.py b/app/ui/split_pane.py new file mode 100644 index 0000000..0c32e11 --- /dev/null +++ b/app/ui/split_pane.py @@ -0,0 +1,150 @@ +"""Affichage de plusieurs terminaux dans un meme onglet (split), via des +QSplitter imbriques. + +Un SplitContainer prend la place d'un onglet normal dans le QTabWidget de +ConnectionController ; chaque panneau (PaneSlot) heberge un widget d'onglet +existant (SessionTabWidget, LocalTerminalTabWidget, ...) sans le modifier. +""" +from __future__ import annotations + +from typing import Callable + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QToolButton, QFrame, +) + +LAYOUT_SINGLE = "1" +LAYOUT_2V = "2v" # 2 panneaux cote a cote (ligne de separation verticale) +LAYOUT_2H = "2h" # 2 panneaux empiles (ligne de separation horizontale) +LAYOUT_4 = "4" # grille 2x2 + +LAYOUT_LABELS = { + LAYOUT_2V: "2 panneaux verticaux (cote a cote)", + LAYOUT_2H: "2 panneaux horizontaux (empiles)", + LAYOUT_4: "4 panneaux", +} + +_PANE_COUNTS = {LAYOUT_SINGLE: 1, LAYOUT_2V: 2, LAYOUT_2H: 2, LAYOUT_4: 4} + + +class PaneSlot(QWidget): + """Un emplacement dans la grille : une fine barre (menu + fermeture) + au-dessus du contenu, qui reste un widget d'onglet inchange.""" + + close_requested = Signal(object) # emet elle-meme + menu_requested = Signal(object) # emet elle-meme ; le conteneur construit le menu + + def __init__(self, content: QWidget, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + bar = QHBoxLayout() + bar.setContentsMargins(4, 2, 4, 2) + self.menu_btn = QToolButton() + self.menu_btn.setText("☰") + self.menu_btn.setToolTip("Changer le contenu de ce panneau") + self.menu_btn.clicked.connect(lambda: self.menu_requested.emit(self)) + bar.addWidget(self.menu_btn) + bar.addStretch() + close_btn = QToolButton() + close_btn.setText("✕") + close_btn.setToolTip("Fermer ce panneau") + close_btn.clicked.connect(lambda: self.close_requested.emit(self)) + bar.addWidget(close_btn) + bar_frame = QFrame() + bar_frame.setObjectName("tabBar") + bar_frame.setLayout(bar) + layout.addWidget(bar_frame) + + self.content = content + layout.addWidget(content, 1) + + def close_connection(self) -> None: + if hasattr(self.content, "close_connection"): + self.content.close_connection() + + def set_content(self, new_content: QWidget) -> None: + self.layout().replaceWidget(self.content, new_content) + self.content.setParent(None) + self.content = new_content + new_content.show() + + +class SplitContainer(QWidget): + """Grille de terminaux (1/2/4 panneaux). Redescend automatiquement a un + seul panneau quand il n'en reste plus qu'un (ferme les autres).""" + + all_panes_closed = Signal(object) # (widget restant) -> le proprietaire deplie l'onglet + pane_menu_requested = Signal(object, object) # (pane, container) -> le proprietaire affiche un menu + + def __init__(self, first_widget: QWidget, layout_kind: str, make_filler: Callable[[], QWidget], parent=None): + super().__init__(parent) + self.panes: list[PaneSlot] = [] + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + contents = [first_widget] + [make_filler() for _ in range(_PANE_COUNTS[layout_kind] - 1)] + for content in contents: + self._add_pane(content) + self._root_layout.addWidget(self._arrange(layout_kind, self.panes)) + + def _make_pane(self, content: QWidget) -> PaneSlot: + pane = PaneSlot(content) + pane.close_requested.connect(self._on_pane_close_requested) + pane.menu_requested.connect(lambda p: self.pane_menu_requested.emit(p, self)) + return pane + + def _add_pane(self, content: QWidget) -> PaneSlot: + pane = self._make_pane(content) + self.panes.append(pane) + return pane + + @staticmethod + def _arrange(layout_kind: str, panes: list[PaneSlot]) -> QWidget: + if layout_kind == LAYOUT_SINGLE: + return panes[0] + if layout_kind == LAYOUT_2V: + splitter = QSplitter(Qt.Horizontal) + elif layout_kind == LAYOUT_2H: + splitter = QSplitter(Qt.Vertical) + elif layout_kind == LAYOUT_4: + top = QSplitter(Qt.Horizontal) + top.addWidget(panes[0]) + top.addWidget(panes[1]) + bottom = QSplitter(Qt.Horizontal) + bottom.addWidget(panes[2]) + bottom.addWidget(panes[3]) + outer = QSplitter(Qt.Vertical) + outer.addWidget(top) + outer.addWidget(bottom) + return outer + else: + raise ValueError(f"layout de split inconnu : {layout_kind}") + for pane in panes: + splitter.addWidget(pane) + return splitter + + def replace_pane_content(self, pane: PaneSlot, new_content: QWidget) -> None: + """Ferme l'ancien contenu du panneau et le remplace (ex : terminal + local par defaut remplace par un onglet deplace ici).""" + pane.close_connection() + pane.set_content(new_content) + + def _on_pane_close_requested(self, pane: PaneSlot) -> None: + pane.close_connection() + self.panes.remove(pane) + # Le retirer de son QSplitter suffit : celui-ci redistribue l'espace + # aux panneaux restants tout seul, sans reconstruire toute la grille. + pane.setParent(None) + pane.deleteLater() + if len(self.panes) <= 1: + remaining = self.panes[0].content if self.panes else None + self.all_panes_closed.emit(remaining) + + def close_all(self) -> None: + for pane in self.panes: + pane.close_connection() diff --git a/tests/test_split_terminal.py b/tests/test_split_terminal.py new file mode 100644 index 0000000..ed55ff0 --- /dev/null +++ b/tests/test_split_terminal.py @@ -0,0 +1,124 @@ +"""Split terminal : diviser un onglet en 2/4 panneaux, deplacer un onglet dans +un panneau, fermer un panneau (redescente automatique a un onglet simple), +fermeture globale d'un onglet divise. + +SSHConnection.start() et LocalTerminalConnection.start() sont neutralisees : +ce module teste l'agencement des panneaux et le cycle de vie des onglets, pas +une vraie connexion (deja couverte par test_keepalive.py / test_bastion.py).""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import itertools +import tempfile +from pathlib import Path +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication, QWidget + +from app.session_manager import SessionStore +from app.key_manager import KeyManager +from app.ssh_connection import SSHConnection +from app.local_terminal import LocalTerminalConnection +from app.controllers.broadcast_manager import BroadcastManager +from app.controllers.connection_controller import ConnectionController +from app.ui.tabs import LocalTerminalTabWidget +from app.ui.split_pane import SplitContainer, LAYOUT_2V, LAYOUT_4 + +app = QApplication.instance() or QApplication([]) + +tmp = Path(tempfile.mkdtemp()) +_counter = itertools.count() + +no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None) +no_local_thread = patch.object(LocalTerminalConnection, "start", lambda self: None) +no_ssh_thread.start() +no_local_thread.start() + + +def make_controller() -> ConnectionController: + n = next(_counter) + session_store = SessionStore(path=tmp / f"sessions_{n}.json") + key_manager = KeyManager(registry_path=tmp / f"keys_{n}.json") + host_widget = QWidget() + broadcast = BroadcastManager(lambda: [host_widget]) + return ConnectionController(session_store, key_manager, broadcast, parent_widget=host_widget) + + +print("=== 1. Diviser un onglet en 2 : le contenu original occupe le 1er panneau ===") +cc = make_controller() +cc.open_local_terminal() +original_widget = cc.tabs.widget(0) +cc.split_tab(0, LAYOUT_2V) +container = cc.tabs.widget(0) +print(" type de l'onglet apres division:", type(container).__name__) +assert isinstance(container, SplitContainer) +assert len(container.panes) == 2 +assert container.panes[0].content is original_widget +assert cc.tabs.count() == 1, "la division ne doit pas creer de nouvel onglet de premier niveau" +print(" OK") + +print() +print("=== 2. Diviser en 4 panneaux ===") +cc2 = make_controller() +cc2.open_local_terminal() +cc2.split_tab(0, LAYOUT_4) +container2 = cc2.tabs.widget(0) +assert len(container2.panes) == 4 +print(" 4 panneaux crees : OK") + +print() +print("=== 3. Deplacer un onglet existant dans un panneau (sans fermer sa connexion) ===") +cc3 = make_controller() +cc3.open_local_terminal() # onglet 0 -> deviendra le split +cc3.open_local_terminal() # onglet 1 -> sera deplace dans un panneau +moved_widget = cc3.tabs.widget(1) +assert cc3.tabs.count() == 2 +cc3.split_tab(0, LAYOUT_2V) +container3 = cc3.tabs.widget(0) +assert cc3.tabs.count() == 2 # l'onglet 1 (independant) existe toujours +target_pane = container3.panes[1] +filler_before_move = target_pane.content +cc3.move_tab_into_pane(1, container3, target_pane) +print(" onglets de premier niveau restants:", cc3.tabs.count()) +assert cc3.tabs.count() == 1, "l'onglet deplace ne doit plus etre un onglet independant" +assert target_pane.content is moved_widget +assert filler_before_move is not moved_widget +print(" OK") + +print() +print("=== 4. Fermer un panneau (sur 2) -> redescente automatique a un onglet simple ===") +cc4 = make_controller() +cc4.open_local_terminal() +original4 = cc4.tabs.widget(0) +cc4.split_tab(0, LAYOUT_2V) +container4 = cc4.tabs.widget(0) +other_pane = container4.panes[1] +other_pane.close_requested.emit(other_pane) +print(" type de l'onglet apres fermeture d'un panneau:", type(cc4.tabs.widget(0)).__name__) +assert cc4.tabs.count() == 1 +assert cc4.tabs.widget(0) is original4, "le panneau restant doit redevenir l'onglet normal" +assert cc4.tabs.tabText(0).startswith("💻"), cc4.tabs.tabText(0) +print(" OK") + +print() +print("=== 5. Fermer un onglet divise (bouton x) ferme toutes les connexions ===") +cc5 = make_controller() +cc5.open_local_terminal() +cc5.split_tab(0, LAYOUT_2V) +container5 = cc5.tabs.widget(0) +panes_before = list(container5.panes) +closed_flags = [] +for pane in panes_before: + original_close = pane.content.close_connection + pane.content.close_connection = lambda o=original_close: (o(), closed_flags.append(True)) +cc5._on_tab_close(0) +print(" connexions fermees:", len(closed_flags), "/", len(panes_before)) +assert len(closed_flags) == len(panes_before) +assert cc5.tabs.count() == 0 +print(" OK") + +no_ssh_thread.stop() +no_local_thread.stop() + +print() +print("SPLIT TERMINAL : TOUS LES TESTS OK")