Files
pyssh-client/app/ui/split_pane.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:46:50 +02:00

188 lines
6.9 KiB
Python

"""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 collections.abc import Callable
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QSplitter,
QToolButton,
QVBoxLayout,
QWidget,
)
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.layout_kind = layout_kind
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()
# ---------- tailles (capture/restauration d'un workspace) ----------
def get_layout_sizes(self) -> list[list[int]]:
"""Tailles (pixels) de chaque QSplitter de la grille, dans un ordre
stable (parcours en profondeur depuis la racine)."""
sizes: list[list[int]] = []
def collect(widget: QWidget) -> None:
if isinstance(widget, QSplitter):
sizes.append(widget.sizes())
for i in range(widget.count()):
collect(widget.widget(i))
root_item = self._root_layout.itemAt(0)
if root_item and root_item.widget():
collect(root_item.widget())
return sizes
def set_layout_sizes(self, sizes: list[list[int]]) -> None:
remaining = list(sizes)
def apply(widget: QWidget) -> None:
if isinstance(widget, QSplitter):
if remaining:
widget.setSizes(remaining.pop(0))
for i in range(widget.count()):
apply(widget.widget(i))
root_item = self._root_layout.itemAt(0)
if root_item and root_item.widget():
apply(root_item.widget())