feat: ajouter les Snippets de commandes

Bibliotheque de commandes organisee par categories (Linux/Docker/Proxmox
fournis en exemples au premier lancement, librement modifiables), avec
recherche.

- app/snippets.py : Snippet/SnippetStore, meme pattern que les autres stores
  (JSON atomique, schema_version).
- SnippetManagerDialog : creer/modifier/supprimer/rechercher, regroupe par
  categorie.
- Integration terminal : le dernier commit du depot a delibrement fait du
  clic droit un raccourci de collage (style terminal Unix), sans menu
  contextuel. Plutot que d'ecraser ce choix recent, les snippets passent par
  un bouton dedie "📋 Snippets" dans la barre de l'onglet (SessionTabWidget et
  LocalTerminalTabWidget), avec un menu Categorie -> Snippet -> Action
  (Inserer dans le terminal / Executer / Executer avec Multi-Send).
  "Inserer" n'envoie jamais Entree (le texte est ecrit, a l'utilisateur de
  valider) ; seul "Executer" l'ajoute.
- BroadcastManager.broadcast_now() : diffusion explicite et ponctuelle vers
  tous les terminaux, independante de l'etat du bouton Multi (utilisee par
  "Executer avec Multi-Send", qui est une action volontaire et pas une frappe
  clavier a relayer).
- La construction du menu (_build_snippets_menu) est separee de son
  affichage (_show_snippets_menu) : testable sans declencher l'appel
  bloquant menu.exec().

tests/test_snippets.py : stockage (CRUD, recherche, categories, persistance)
et integration terminal (inserer/executer/multi-send, construction et
declenchement du menu), avec LocalTerminalConnection.start() neutralisee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 02:55:10 +02:00
parent a3c72ade0b
commit 0a07ddcb62
8 changed files with 545 additions and 10 deletions

View File

@@ -15,7 +15,7 @@ from PySide6.QtCore import Qt, Signal, QTimer
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter,
QFrame, QLineEdit, QFileDialog,
QFrame, QLineEdit, QFileDialog, QMenu,
)
from app.session_manager import Session, AUTH_KEY, AUTH_PASSWORD
@@ -24,10 +24,12 @@ from app.local_terminal import LocalTerminalConnection, SHELL_POWERSHELL, SHELL_
from app.terminal_widget import TerminalWidget
from app.sftp_panel import SFTPPanel
from app.dialogs.tunnel_dialog import TunnelDialog
from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.tunnels import TunnelManager
from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change
from app.services.ssh_target import detect_ssh_target
from app.services.reconnect import compute_backoff_delay
from app.snippets import Snippet, SnippetStore
# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
ANSI_ESCAPE_RE = re.compile(
@@ -44,8 +46,14 @@ class BaseTerminalTab(QWidget):
connexion (SSH ou locale) qui expose la meme interface de signaux
(connected / data_received / disconnected / error / send / resize)."""
def __init__(self, initial_status: str, parent=None):
def __init__(
self, initial_status: str, snippet_store: SnippetStore | None = None,
broadcast=None, parent=None,
):
super().__init__(parent)
self.snippet_store = snippet_store
self.broadcast = broadcast
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@@ -65,6 +73,15 @@ class BaseTerminalTab(QWidget):
self.reconnect_btn.clicked.connect(self.reconnect)
self.bar.addWidget(self.reconnect_btn)
# Clic droit = coller (comme un terminal Unix) : les snippets passent donc
# par ce bouton plutot que par un menu contextuel, qui entrerait en conflit.
self.snippets_btn = QToolButton()
self.snippets_btn.setText("📋 Snippets")
self.snippets_btn.setToolTip("Inserer ou executer une commande enregistree")
self.snippets_btn.setPopupMode(QToolButton.InstantPopup)
self.snippets_btn.clicked.connect(self._show_snippets_menu)
self.bar.addWidget(self.snippets_btn)
self.log_btn = QToolButton()
self.log_btn.setText("⏺ Journal")
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
@@ -204,6 +221,54 @@ class BaseTerminalTab(QWidget):
self.reconnect_btn.show()
QMessageBox.critical(self, "Erreur", message)
# ---------- snippets ----------
def _show_snippets_menu(self) -> None:
menu = self._build_snippets_menu()
menu.exec(self.snippets_btn.mapToGlobal(self.snippets_btn.rect().bottomLeft()))
def _build_snippets_menu(self) -> QMenu:
menu = QMenu(self)
if self.snippet_store is None or not self.snippet_store.snippets:
hint = menu.addAction("(aucun snippet)")
hint.setEnabled(False)
else:
for category, snippets in self.snippet_store.by_category().items():
cat_menu = menu.addMenu(category)
for snippet in snippets:
snippet_menu = cat_menu.addMenu(snippet.name)
if snippet.description:
snippet_menu.setToolTip(snippet.description)
insert_action = snippet_menu.addAction("Inserer dans le terminal")
insert_action.triggered.connect(lambda checked=False, s=snippet: self._insert_snippet(s))
exec_action = snippet_menu.addAction("Executer")
exec_action.triggered.connect(lambda checked=False, s=snippet: self._execute_snippet(s))
if self.broadcast is not None:
multi_action = snippet_menu.addAction("Executer avec Multi-Send")
multi_action.triggered.connect(
lambda checked=False, s=snippet: self._execute_snippet(s, multi=True)
)
menu.addSeparator()
manage_action = menu.addAction("Gerer les snippets...")
manage_action.triggered.connect(self._manage_snippets)
return menu
def _manage_snippets(self) -> None:
if self.snippet_store is None:
return
SnippetManagerDialog(self.snippet_store, parent=self).exec()
def _insert_snippet(self, snippet: Snippet) -> None:
"""N'envoie jamais Entree : le texte est ecrit, a l'utilisateur de valider."""
if self.connection:
self.connection.send(snippet.command.encode("utf-8"))
def _execute_snippet(self, snippet: Snippet, multi: bool = False) -> None:
data = snippet.command.encode("utf-8") + b"\r"
if multi and self.broadcast is not None:
self.broadcast.broadcast_now(data)
elif self.connection:
self.connection.send(data)
# ---------- recherche dans l'historique ----------
def open_search(self) -> None:
self.search_bar.show()
@@ -235,9 +300,12 @@ class SessionTabWidget(BaseTerminalTab):
def __init__(
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
parent=None,
snippet_store=None, broadcast=None, parent=None,
):
super().__init__(f"○ Connexion a {session.username}@{session.host}...", parent)
super().__init__(
f"○ Connexion a {session.username}@{session.host}...",
snippet_store=snippet_store, broadcast=broadcast, parent=parent,
)
self.session = session
self._bastion_target = bastion_target
self._bastion_password = bastion_password
@@ -526,8 +594,11 @@ class LocalTerminalTabWidget(BaseTerminalTab):
sftp_requested = Signal(object) # cible ssh detectee (ou None)
def __init__(self, shell: str = SHELL_POWERSHELL, parent=None):
super().__init__(f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...", parent)
def __init__(self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None, parent=None):
super().__init__(
f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...",
snippet_store=snippet_store, broadcast=broadcast, parent=parent,
)
self.shell = shell
# Meme dans un terminal local, on tape souvent "ssh user@ip" a la main :