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

283 lines
12 KiB
Python

"""Fenetre principale : assemble le panneau des sessions, les onglets, les menus.
La logique metier vit dans des controleurs dedies (app.controllers) et les widgets
d'onglet dans app.ui.tabs ; cette classe se contente de les construire et de les
relier.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QActionGroup, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QDockWidget,
QMainWindow,
QMessageBox,
QStatusBar,
)
from app.ai.settings import build_provider_from_settings
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController
from app.controllers.session_controller import SessionController
from app.controllers.workspace_manager import WorkspaceManager
from app.credentials import default_credential_store
from app.dialogs.ai_settings_dialog import AISettingsDialog
from app.dialogs.key_dialog import KeyManagerDialog
from app.dialogs.known_hosts_dialog import KnownHostsDialog
from app.dialogs.quick_connect_dialog import QuickConnectDialog
from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.dialogs.workspace_dialog import WorkspaceDialog
from app.groups import GroupSettingsStore
from app.key_manager import KeyManager
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
# 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
from app.session_manager import SessionStore
from app.snippets import SnippetStore
from app.terminal_widget import TerminalWidget
from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme
from app.ui.split_pane import SplitContainer
from app.workspace import WorkspaceStore
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PySSH Client")
self.resize(1280, 820)
self.session_store = SessionStore()
self.key_manager = KeyManager()
self.workspace_store = WorkspaceStore()
self.snippet_store = SnippetStore()
self.group_store = GroupSettingsStore()
self.credential_store = default_credential_store()
self.broadcast = BroadcastManager(self._iter_tabs, self)
self.connection_controller = ConnectionController(
self.session_store, self.key_manager, self.broadcast,
snippet_store=self.snippet_store, credential_store=self.credential_store,
ai_provider_factory=lambda: build_provider_from_settings(self.credential_store),
parent_widget=self,
)
self.session_controller = SessionController(
self.session_store, self.key_manager, self.broadcast.button, parent_widget=self,
group_store=self.group_store, credential_store=self.credential_store,
)
self.workspace_manager = WorkspaceManager(
self.workspace_store, self.session_store, self.connection_controller, parent_widget=self,
)
self._wire_controllers()
self._build_menu()
self._build_sidebar()
self.setCentralWidget(self.connection_controller.tabs)
self.setStatusBar(QStatusBar())
# demarre directement dans un terminal utilisable, sans avoir a en choisir un -
# sauf si un workspace vient d'etre restaure automatiquement (il a deja le sien)
if not self.workspace_manager.try_restore_last_on_startup():
self.connection_controller.open_local_terminal(DEFAULT_SHELL)
# ---------- acces retro-compatible (tests, code externe) ----------
@property
def tabs(self):
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
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:
sc, cc = self.session_controller, self.connection_controller
sc.open_session_requested.connect(cc.open_session)
sc.open_sftp_requested.connect(cc.open_sftp_session)
sc.open_local_shell_requested.connect(cc.open_local_terminal)
sc.open_openssh_host_requested.connect(cc.open_openssh_host)
sc.quick_sftp_requested.connect(cc.quick_sftp)
sc.manage_keys_requested.connect(self.open_key_manager)
sc.manage_known_hosts_requested.connect(self.open_known_hosts_manager)
cc.session_saved.connect(sc.reload)
# ---------- construction UI ----------
def _build_menu(self) -> None:
file_menu = self.menuBar().addMenu("&Fichier")
quick_connect_action = QAction("Recherche rapide...", self)
quick_connect_action.setShortcut(QKeySequence("Ctrl+K"))
quick_connect_action.triggered.connect(self.open_quick_connect)
file_menu.addAction(quick_connect_action)
file_menu.addSeparator()
new_session_action = QAction("Nouvelle session...", self)
new_session_action.setShortcut(QKeySequence("Ctrl+N"))
new_session_action.triggered.connect(self.session_controller.new_session)
file_menu.addAction(new_session_action)
file_menu.addSeparator()
quick_ssh_action = QAction("Connexion SSH rapide...", self)
quick_ssh_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
quick_ssh_action.triggered.connect(self.connection_controller.quick_ssh)
file_menu.addAction(quick_ssh_action)
quick_sftp_action = QAction("Connexion SFTP (gestionnaire de fichiers)...", self)
quick_sftp_action.setShortcut(QKeySequence("Ctrl+Shift+F"))
quick_sftp_action.triggered.connect(self.connection_controller.quick_sftp)
file_menu.addAction(quick_sftp_action)
file_menu.addSeparator()
default_action = QAction(
f"Nouveau terminal local ({SHELL_LABELS.get(DEFAULT_SHELL, DEFAULT_SHELL)})", self
)
default_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
default_action.triggered.connect(lambda: self.connection_controller.open_local_terminal(DEFAULT_SHELL))
file_menu.addAction(default_action)
others_menu = file_menu.addMenu("Autre terminal local")
for shell_id, label in SHELL_LABELS.items():
others_menu.addAction(label, lambda s=shell_id: self.connection_controller.open_local_terminal(s))
file_menu.addSeparator()
quit_action = QAction("Quitter", self)
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
keys_menu = self.menuBar().addMenu("&Cles SSH")
manage_keys_action = QAction("Gestionnaire de cles...", self)
manage_keys_action.triggered.connect(self.open_key_manager)
keys_menu.addAction(manage_keys_action)
view_menu = self.menuBar().addMenu("&Affichage")
self.toggle_sidebar_action = QAction("Panneau des sessions", self, checkable=True)
self.toggle_sidebar_action.setChecked(True)
self.toggle_sidebar_action.setShortcut(QKeySequence("F9"))
self.toggle_sidebar_action.triggered.connect(self.toggle_sidebar)
view_menu.addAction(self.toggle_sidebar_action)
view_menu.addSeparator()
theme_menu = view_menu.addMenu("Theme")
self.theme_group = QActionGroup(self)
self.theme_group.setExclusive(True)
for theme_id, label in THEME_LABELS.items():
action = QAction(label, self, checkable=True)
action.setChecked(theme_id == current_theme())
action.triggered.connect(lambda _checked=False, t=theme_id: self.change_theme(t))
self.theme_group.addAction(action)
theme_menu.addAction(action)
hosts_menu = self.menuBar().addMenu("&Hotes connus")
manage_hosts_action = QAction("Gestionnaire des hotes connus...", self)
manage_hosts_action.triggered.connect(self.open_known_hosts_manager)
hosts_menu.addAction(manage_hosts_action)
workspaces_menu = self.menuBar().addMenu("&Workspaces")
manage_workspaces_action = QAction("Gestionnaire de workspaces...", self)
manage_workspaces_action.setShortcut(QKeySequence("Ctrl+Shift+W"))
manage_workspaces_action.triggered.connect(self.open_workspace_manager)
workspaces_menu.addAction(manage_workspaces_action)
snippets_menu = self.menuBar().addMenu("&Snippets")
manage_snippets_action = QAction("Gestionnaire de snippets...", self)
manage_snippets_action.triggered.connect(self.open_snippet_manager)
snippets_menu.addAction(manage_snippets_action)
ai_menu = self.menuBar().addMenu("&Assistant IA")
ai_settings_action = QAction("Parametres...", self)
ai_settings_action.triggered.connect(self.open_ai_settings)
ai_menu.addAction(ai_settings_action)
help_menu = self.menuBar().addMenu("&Aide")
about_action = QAction("A propos", self)
about_action.triggered.connect(self.show_about)
help_menu.addAction(about_action)
def _build_sidebar(self) -> None:
dock = QDockWidget("Sessions", self)
# Closable : sans cette option, le panneau n'a pas de bouton de fermeture
# et ne peut pas etre masque.
dock.setFeatures(
QDockWidget.DockWidgetMovable
| QDockWidget.DockWidgetFloatable
| QDockWidget.DockWidgetClosable
)
self.sessions_dock = dock
dock.setWidget(self.session_controller.widget)
self.addDockWidget(Qt.LeftDockWidgetArea, dock)
self.resizeDocks([dock], [300], Qt.Horizontal)
# garde la case du menu Affichage synchronisee si on ferme le panneau par sa croix
dock.visibilityChanged.connect(self.toggle_sidebar_action.setChecked)
# ---------- cles / affichage / a propos ----------
def toggle_sidebar(self, visible: bool | None = None) -> None:
"""Affiche ou masque le panneau des sessions (F9)."""
if visible is None:
visible = not self.sessions_dock.isVisible()
self.sessions_dock.setVisible(visible)
self.toggle_sidebar_action.setChecked(visible)
def change_theme(self, theme: str) -> None:
"""Applique un theme a chaud : feuille de style de l'interface + couleurs des
terminaux deja ouverts (qui ont leur propre palette, hors QSS)."""
app = QApplication.instance()
if app is None:
return
apply_theme(app, theme)
save_theme(theme)
for widget in self.findChildren(TerminalWidget):
widget.refresh_theme()
def open_key_manager(self) -> None:
dialog = KeyManagerDialog(self.key_manager, parent=self)
dialog.exec()
def open_known_hosts_manager(self) -> None:
dialog = KnownHostsDialog(parent=self)
dialog.exec()
def open_workspace_manager(self) -> None:
dialog = WorkspaceDialog(self.workspace_manager, parent=self)
dialog.exec()
def open_snippet_manager(self) -> None:
dialog = SnippetManagerDialog(self.snippet_store, parent=self)
dialog.exec()
def open_ai_settings(self) -> None:
dialog = AISettingsDialog(credential_store=self.credential_store, parent=self)
dialog.exec()
def open_quick_connect(self) -> None:
dialog = QuickConnectDialog(
self.session_store, self.connection_controller, self.workspace_manager,
self.snippet_store, self.session_controller, self.key_manager, parent=self,
)
dialog.exec()
def show_about(self) -> None:
QMessageBox.information(
self, "A propos",
"PySSH Client\n\nClient SSH/SFTP local avec gestion de sessions, de cles, "
"et terminaux locaux integres.\nBase sur PySide6, paramiko, pywinpty et pyte.",
)
def closeEvent(self, event) -> None:
self.connection_controller.close_all()
super().closeEvent(event)