Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de secrets, detection de commandes dangereuses, parametres, worker QThread) et son integration dans chaque onglet terminal via un bouton "Assistant IA" (memes conventions que le bouton Snippets existant) : - Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution / Poser une question... : utilisent la selection courante du terminal, affichees dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le splitter comme le panneau SFTP. - Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer dans le terminal sans Entree / Annuler), avec avertissement si la commande proposee correspond a un motif destructeur connu. - Avant tout envoi : avis unique (persist) puis detection de secrets evidents (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler. Desactive par defaut, sans liste de fournisseur figee (URL + modele libres, compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par CredentialStore (jamais dans settings.json ni journalisee). Parametrable via le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion"). Bug trouve par les tests avant commit : un panneau nouvellement insere dans un QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement a sa creation. 9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker, confirmations de securite, dialogue de commande, panneau, integration onglet) : 39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans regression. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
279 lines
12 KiB
Python
279 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 (
|
|
QMainWindow, QDockWidget, QMessageBox, QStatusBar, QApplication,
|
|
)
|
|
|
|
from app.session_manager import SessionStore
|
|
from app.key_manager import KeyManager
|
|
from app.local_terminal import SHELL_LABELS, DEFAULT_SHELL
|
|
from app.terminal_widget import TerminalWidget
|
|
from app.dialogs.key_dialog import KeyManagerDialog
|
|
from app.dialogs.known_hosts_dialog import KnownHostsDialog
|
|
from app.dialogs.workspace_dialog import WorkspaceDialog
|
|
from app.dialogs.snippet_dialog import SnippetManagerDialog
|
|
from app.dialogs.quick_connect_dialog import QuickConnectDialog
|
|
from app.dialogs.ai_settings_dialog import AISettingsDialog
|
|
from app.ai.settings import build_provider_from_settings
|
|
from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme
|
|
from app.workspace import WorkspaceStore
|
|
from app.snippets import SnippetStore
|
|
from app.groups import GroupSettingsStore
|
|
from app.credentials import default_credential_store
|
|
from app.controllers.broadcast_manager import BroadcastManager
|
|
from app.controllers.session_controller import SessionController
|
|
from app.controllers.connection_controller import ConnectionController
|
|
from app.controllers.workspace_manager import WorkspaceManager
|
|
from app.ui.split_pane import SplitContainer
|
|
|
|
# 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
|
|
|
|
|
|
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)
|