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

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