Files
pyssh-client/app/controllers/connection_controller.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

366 lines
16 KiB
Python

"""Cycle de vie des onglets (terminal SSH, terminal local, SFTP autonome) :
ouverture, connexion rapide, renommage, fermeture."""
from __future__ import annotations
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTabWidget, QWidget
from app.bastion import BastionResolutionError, BastionTarget, resolve_bastion_target
from app.controllers.broadcast_manager import BroadcastManager
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
from app.dialogs.connect_prompt import ask_secret
from app.dialogs.session_dialog import SessionDialog
from app.key_manager import KeyManager
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
from app.openssh.config import OpenSSHHost, session_from_openssh_host
from app.services.ssh_target import detect_ssh_target
from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore
from app.ui.split_pane import LAYOUT_LABELS, PaneSlot, SplitContainer
from app.ui.tabs import BaseTerminalTab, LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget
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)