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>
366 lines
16 KiB
Python
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 Qt, Signal, QObject
|
|
from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog
|
|
|
|
from app.session_manager import SessionStore, Session, AUTH_PASSWORD, PASSWORD_STORAGE_SECURE
|
|
from app.key_manager import KeyManager
|
|
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
|
|
from app.dialogs.session_dialog import SessionDialog
|
|
from app.dialogs.connect_prompt import ask_secret
|
|
from app.services.ssh_target import detect_ssh_target
|
|
from app.openssh.config import OpenSSHHost, session_from_openssh_host
|
|
from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError
|
|
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
|
|
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):
|
|
"""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)
|