feat: ajouter le split terminal (plusieurs panneaux par onglet)

Layouts : 1 (normal), 2 verticaux (cote a cote), 2 horizontaux (empiles), 4
panneaux (grille 2x2), via des QSplitter imbriques (app/ui/split_pane.py).
Chaque panneau reutilise tel quel un widget d'onglet existant
(SessionTabWidget, LocalTerminalTabWidget) : aucune modification de ces
classes, seule une fine barre (menu ☰ + fermeture) les enveloppe.

- Menu contextuel d'un onglet (SSH ou terminal local, pas SFTP, pas
  d'imbrication de splits) : "Diviser" -> choix du layout. Le contenu actuel
  occupe le premier panneau, les autres recoivent un terminal local par
  defaut.
- Menu ☰ d'un panneau : changer son contenu (autre terminal local), ou
  "Deplacer un onglet ici" pour y faire migrer un onglet de premier niveau
  existant sans fermer sa connexion.
- Fermer un panneau le retire de son QSplitter (qui redistribue l'espace tout
  seul) ; s'il n'en reste qu'un, l'onglet redevient un onglet normal
  (redescente automatique, pas de chrome de split residuel).
- Fermer l'onglet divise (bouton x) ferme toutes les connexions des panneaux.
- BroadcastManager (Multi-Send) atteint desormais aussi les panneaux a
  l'interieur d'un split (MainWindow._iter_tabs les deplie).

Bug intercepte par les tests avant integration : reparenter le widget d'origine
dans son panneau retire automatiquement son onglet du QTabWidget (un widget ne
peut avoir qu'un seul parent), ce qui decale les onglets suivants ; un appel
explicite a removeTab() ensuite visait donc le mauvais onglet. Corrige en
laissant Qt gerer ce retrait implicitement plutot que de le dupliquer.

tests/test_split_terminal.py : SSHConnection.start()/LocalTerminalConnection.start()
neutralisees (teste l'agencement des panneaux et le cycle de vie des onglets,
pas une connexion reelle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 02:35:39 +02:00
parent 9c8017cebd
commit 5917e1ca04
4 changed files with 399 additions and 6 deletions

View File

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