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

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist/
data/sessions.json*
data/keys.json*
data/workspaces.json*
data/snippets.json*
data/known_hosts*
data/keys/*
!data/keys/.gitkeep

View File

@@ -35,11 +35,20 @@ class BroadcastManager(QObject):
def _broadcast(self, data: bytes, source) -> None:
if not self.enabled:
return
self._send_to_all(data, exclude=source)
def broadcast_now(self, data: bytes) -> None:
"""Diffuse immediatement vers tous les terminaux, independamment de l'etat
du bouton Multi : declenchement explicite et ponctuel (ex : un snippet
execute avec « Executer avec Multi-Send »), pas une frappe clavier."""
self._send_to_all(data, exclude=None)
def _send_to_all(self, data: bytes, exclude) -> None:
# import tardif : evite un cycle (app.ui.tabs importe des dialogues, pas ce module)
from app.ui.tabs import BaseTerminalTab
for tab in self._tabs_provider():
if tab is source or not isinstance(tab, BaseTerminalTab):
if tab is exclude or not isinstance(tab, BaseTerminalTab):
continue
if tab.connection:
# on ecrit directement dans la connexion : passer par le terminal

View File

@@ -30,12 +30,14 @@ class ConnectionController(QObject):
session_store: SessionStore,
key_manager: KeyManager,
broadcast: BroadcastManager,
snippet_store=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._parent_widget = parent_widget
self.tabs = QTabWidget(parent_widget)
@@ -85,7 +87,10 @@ class ConnectionController(QObject):
ok, secret = self._ask_session_password(session)
if not ok:
return None
return SessionTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password)
return SessionTabWidget(
session, secret, bastion_target=bastion_target, bastion_password=bastion_password,
snippet_store=self.snippet_store, broadcast=self.broadcast,
)
def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None:
ok, bastion_target, bastion_password = self._resolve_bastion(session)
@@ -126,7 +131,7 @@ class ConnectionController(QObject):
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)
tab = LocalTerminalTabWidget(shell, snippet_store=self.snippet_store, broadcast=self.broadcast)
tab.sftp_requested.connect(self.quick_sftp)
self.broadcast.register(tab)
return tab

View File

@@ -0,0 +1,170 @@
"""Gestionnaire de snippets : creer, modifier, supprimer, rechercher, organiser
par categorie."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton,
QMessageBox, QLineEdit, QLabel, QFormLayout, QTextEdit, QDialogButtonBox, QComboBox,
)
from app.snippets import Snippet, SnippetStore
SNIPPET_ROLE = Qt.UserRole
class SnippetEditDialog(QDialog):
def __init__(self, snippet: Snippet | None = None, categories: list[str] | None = None, parent=None):
super().__init__(parent)
self.snippet = snippet or Snippet()
self.setWindowTitle("Nouveau snippet" if snippet is None else "Modifier le snippet")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
form = QFormLayout()
self.name_edit = QLineEdit(self.snippet.name)
self.category_combo = QComboBox()
self.category_combo.setEditable(True)
for cat in categories or []:
self.category_combo.addItem(cat)
self.category_combo.setCurrentText(self.snippet.category)
self.description_edit = QLineEdit(self.snippet.description)
self.command_edit = QTextEdit(self.snippet.command)
self.command_edit.setFixedHeight(80)
self.command_edit.setPlaceholderText("ex : docker ps -a")
form.addRow("Nom :", self.name_edit)
form.addRow("Categorie :", self.category_combo)
form.addRow("Description :", self.description_edit)
form.addRow("Commande :", self.command_edit)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _on_accept(self) -> None:
if not self.name_edit.text().strip():
QMessageBox.warning(self, "Nom manquant", "Indiquez un nom pour ce snippet.")
self.name_edit.setFocus()
return
if not self.command_edit.toPlainText().strip():
QMessageBox.warning(self, "Commande manquante", "Indiquez la commande du snippet.")
self.command_edit.setFocus()
return
self.snippet.name = self.name_edit.text().strip()
self.snippet.category = self.category_combo.currentText().strip()
self.snippet.description = self.description_edit.text().strip()
self.snippet.command = self.command_edit.toPlainText().rstrip("\n")
self.accept()
def result_snippet(self) -> Snippet:
return self.snippet
class SnippetManagerDialog(QDialog):
def __init__(self, store: SnippetStore, parent=None):
super().__init__(parent)
self.store = store
self.setWindowTitle("Gestionnaire de snippets")
self.setMinimumSize(560, 420)
layout = QVBoxLayout(self)
self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText("Rechercher (nom, categorie, description, commande)...")
self.search_edit.textChanged.connect(self.refresh)
layout.addWidget(self.search_edit)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Nom", "Description"])
self.tree.setColumnWidth(0, 220)
self.tree.itemDoubleClicked.connect(lambda _i, _c: self.edit_selected())
self.tree.itemSelectionChanged.connect(self._update_preview)
layout.addWidget(self.tree)
self.command_preview = QLabel("")
self.command_preview.setWordWrap(True)
self.command_preview.setObjectName("statusLabel")
layout.addWidget(self.command_preview)
btn_row = QHBoxLayout()
self.new_btn = QPushButton("Nouveau...")
self.edit_btn = QPushButton("Modifier...")
self.delete_btn = QPushButton("Supprimer")
for b in (self.new_btn, self.edit_btn, self.delete_btn):
btn_row.addWidget(b)
layout.addLayout(btn_row)
close_row = QHBoxLayout()
close_row.addStretch()
close_btn = QPushButton("Fermer")
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self.new_btn.clicked.connect(self.create_new)
self.edit_btn.clicked.connect(self.edit_selected)
self.delete_btn.clicked.connect(self.delete_selected)
self.refresh()
def refresh(self) -> None:
self.tree.clear()
query = self.search_edit.text()
matches = {s.id for s in self.store.search(query)} if query.strip() else None
for category, snippets in self.store.by_category().items():
visible = [s for s in snippets if matches is None or s.id in matches]
if not visible:
continue
cat_item = QTreeWidgetItem([category])
cat_item.setFlags(cat_item.flags() & ~Qt.ItemIsSelectable)
font = cat_item.font(0)
font.setBold(True)
cat_item.setFont(0, font)
self.tree.addTopLevelItem(cat_item)
for snippet in visible:
item = QTreeWidgetItem([snippet.name, snippet.description])
item.setData(0, SNIPPET_ROLE, snippet.id)
cat_item.addChild(item)
self.tree.expandAll()
self._update_preview()
def _current_snippet(self) -> Snippet | None:
item = self.tree.currentItem()
if not item:
return None
snippet_id = item.data(0, SNIPPET_ROLE)
return self.store.get(snippet_id) if snippet_id else None
def _update_preview(self) -> None:
snippet = self._current_snippet()
self.command_preview.setText(snippet.command if snippet else "")
has_selection = snippet is not None
self.edit_btn.setEnabled(has_selection)
self.delete_btn.setEnabled(has_selection)
def create_new(self) -> None:
dialog = SnippetEditDialog(categories=self.store.categories(), parent=self)
if dialog.exec():
self.store.add(dialog.result_snippet())
self.refresh()
def edit_selected(self) -> None:
snippet = self._current_snippet()
if not snippet:
return
dialog = SnippetEditDialog(snippet=snippet, categories=self.store.categories(), parent=self)
if dialog.exec():
self.store.update(dialog.result_snippet())
self.refresh()
def delete_selected(self) -> None:
snippet = self._current_snippet()
if not snippet:
return
if QMessageBox.question(self, "Supprimer", f"Supprimer le snippet « {snippet.name} » ?") == QMessageBox.Yes:
self.store.delete(snippet.id)
self.refresh()

View File

@@ -19,8 +19,10 @@ 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.theme import THEME_LABELS, apply_theme, current_theme, save_theme
from app.workspace import WorkspaceStore
from app.snippets import SnippetStore
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.session_controller import SessionController
from app.controllers.connection_controller import ConnectionController
@@ -40,10 +42,12 @@ class MainWindow(QMainWindow):
self.session_store = SessionStore()
self.key_manager = KeyManager()
self.workspace_store = WorkspaceStore()
self.snippet_store = SnippetStore()
self.broadcast = BroadcastManager(self._iter_tabs, self)
self.connection_controller = ConnectionController(
self.session_store, self.key_manager, self.broadcast, parent_widget=self,
self.session_store, self.key_manager, self.broadcast,
snippet_store=self.snippet_store, parent_widget=self,
)
self.session_controller = SessionController(
self.session_store, self.key_manager, self.broadcast.button, parent_widget=self,
@@ -166,6 +170,11 @@ class MainWindow(QMainWindow):
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)
help_menu = self.menuBar().addMenu("&Aide")
about_action = QAction("A propos", self)
about_action.triggered.connect(self.show_about)
@@ -219,6 +228,10 @@ class MainWindow(QMainWindow):
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 show_about(self) -> None:
QMessageBox.information(
self, "A propos",

118
app/snippets.py Normal file
View File

@@ -0,0 +1,118 @@
"""Snippets de commandes : bibliotheque organisee par categories, utilisable
depuis un terminal (inserer sans executer, executer, executer avec Multi-Send)."""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field, asdict
from pathlib import Path
from app.paths import DATA_DIR
from app.storage.json_store import load_json_document, save_json_document
SNIPPETS_FILE = DATA_DIR / "snippets.json"
SCHEMA_VERSION = 1
@dataclass
class Snippet:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
category: str = ""
description: str = ""
command: str = ""
def to_dict(self) -> dict:
return asdict(self)
@staticmethod
def from_dict(data: dict) -> "Snippet":
s = Snippet()
for k in Snippet.__dataclass_fields__:
if k in data:
setattr(s, k, data[k])
return s
# Quelques exemples fournis d'origine (premier lancement uniquement) : l'utilisateur
# peut les modifier ou les supprimer librement, ce ne sont que des points de depart.
DEFAULT_SNIPPETS: list[Snippet] = [
Snippet(name="Disk Usage", category="Linux",
description="Espace disque par systeme de fichiers", command="df -h"),
Snippet(name="Failed Services", category="Linux",
description="Services systemd en echec", command="systemctl --failed"),
Snippet(name="Listening Ports", category="Linux",
description="Ports en ecoute", command="ss -tulpn"),
Snippet(name="Containers", category="Docker",
description="Conteneurs (tous statuts)", command="docker ps -a"),
Snippet(name="Docker Cleanup", category="Docker",
description="Nettoyer images/conteneurs/volumes inutilises", command="docker system prune -a"),
Snippet(name="Logs", category="Docker",
description="Logs d'un conteneur (a completer avec son nom)", command="docker logs -f "),
Snippet(name="Cluster Status", category="Proxmox",
description="Etat du cluster Proxmox", command="pvecm status"),
Snippet(name="VM List", category="Proxmox",
description="Liste des machines virtuelles", command="qm list"),
]
class SnippetStore:
"""Charge/sauvegarde la liste des snippets depuis data/snippets.json."""
def __init__(self, path: Path = SNIPPETS_FILE):
self.path = path
self.snippets: list[Snippet] = []
self.load_warning: str | None = None
self.load()
def load(self) -> None:
was_missing = not self.path.exists()
document, warning = load_json_document(
self.path,
default={"schema_version": SCHEMA_VERSION, "snippets": [s.to_dict() for s in DEFAULT_SNIPPETS]},
current_version=SCHEMA_VERSION,
)
self.load_warning = warning
self.snippets = [Snippet.from_dict(s) for s in document.get("snippets", [])]
if was_missing:
self.save()
def save(self) -> None:
payload = {"schema_version": SCHEMA_VERSION, "snippets": [s.to_dict() for s in self.snippets]}
save_json_document(self.path, payload)
def get(self, snippet_id: str) -> Snippet | None:
return next((s for s in self.snippets if s.id == snippet_id), None)
def add(self, snippet: Snippet) -> None:
self.snippets.append(snippet)
self.save()
def update(self, snippet: Snippet) -> None:
for i, s in enumerate(self.snippets):
if s.id == snippet.id:
self.snippets[i] = snippet
break
self.save()
def delete(self, snippet_id: str) -> None:
self.snippets = [s for s in self.snippets if s.id != snippet_id]
self.save()
def categories(self) -> list[str]:
return sorted({s.category for s in self.snippets if s.category})
def by_category(self) -> dict[str, list[Snippet]]:
grouped: dict[str, list[Snippet]] = {}
for s in sorted(self.snippets, key=lambda s: s.name.lower()):
grouped.setdefault(s.category or "Sans categorie", []).append(s)
return dict(sorted(grouped.items(), key=lambda kv: kv[0].lower()))
def search(self, query: str) -> list[Snippet]:
q = query.lower().strip()
if not q:
return list(self.snippets)
return [
s for s in self.snippets
if q in s.name.lower() or q in s.category.lower()
or q in s.description.lower() or q in s.command.lower()
]

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 :

148
tests/test_snippets.py Normal file
View File

@@ -0,0 +1,148 @@
"""Snippets : stockage (CRUD, recherche, categories) et integration terminal
(inserer sans executer, executer, executer avec Multi-Send).
LocalTerminalConnection.start() est neutralisee : send() ne fait que deposer
les octets dans une file interne (aucun sous-processus reel), ce qui suffit a
verifier ce qui a ete envoye sans avoir besoin d'un vrai terminal."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QWidget
from app.snippets import Snippet, SnippetStore, DEFAULT_SNIPPETS
from app.local_terminal import LocalTerminalConnection
from app.controllers.broadcast_manager import BroadcastManager
from app.ui.tabs import LocalTerminalTabWidget
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
print("=== 1. Premier lancement : les exemples fournis sont charges ===")
store = SnippetStore(path=tmp / "snippets.json")
print(f" {len(store.snippets)} snippet(s) au premier lancement")
assert len(store.snippets) == len(DEFAULT_SNIPPETS)
assert any(s.name == "Disk Usage" and s.category == "Linux" for s in store.snippets)
print(" OK")
print()
print("=== 2. CRUD ===")
store2 = SnippetStore(path=tmp / "snippets2.json")
initial_count = len(store2.snippets)
snippet = Snippet(name="Mon snippet", category="Perso", description="test", command="echo bonjour")
store2.add(snippet)
assert len(store2.snippets) == initial_count + 1
snippet.command = "echo modifie"
store2.update(snippet)
assert store2.get(snippet.id).command == "echo modifie"
store2.delete(snippet.id)
assert store2.get(snippet.id) is None
assert len(store2.snippets) == initial_count
print(" OK")
print()
print("=== 3. Categories et regroupement ===")
store3 = SnippetStore(path=tmp / "snippets3.json")
store3.snippets = [
Snippet(name="b", category="Docker", command="x"),
Snippet(name="a", category="Docker", command="y"),
Snippet(name="z", category="Linux", command="z"),
]
grouped = store3.by_category()
print(" categories:", list(grouped.keys()))
assert list(grouped.keys()) == ["Docker", "Linux"]
assert [s.name for s in grouped["Docker"]] == ["a", "b"] # tries par nom
print(" OK")
print()
print("=== 4. Recherche ===")
results = store3.search("docker")
assert {s.category for s in results} == {"Docker"}
results = store3.search("")
assert len(results) == len(store3.snippets)
print(" OK")
print()
print("=== 5. Persistance (relecture depuis le disque) ===")
path4 = tmp / "snippets4.json"
store4 = SnippetStore(path=path4)
store4.add(Snippet(name="persist", category="Test", command="echo test"))
reloaded = SnippetStore(path=path4)
assert any(s.name == "persist" for s in reloaded.snippets)
print(" OK")
print()
print("=== 6. Terminal : Inserer n'envoie jamais Entree ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = LocalTerminalTabWidget()
snippet = Snippet(name="test", command="echo bonjour")
tab._insert_snippet(snippet)
sent = tab.connection._out_queue.get_nowait()
print(" envoye:", sent)
assert sent == b"echo bonjour"
assert not sent.endswith(b"\r") and not sent.endswith(b"\n")
tab.close_connection()
print(" OK")
print()
print("=== 7. Terminal : Executer envoie la commande suivie d'Entree ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = LocalTerminalTabWidget()
tab._execute_snippet(Snippet(name="test", command="echo bonjour"))
sent = tab.connection._out_queue.get_nowait()
print(" envoye:", sent)
assert sent == b"echo bonjour\r"
tab.close_connection()
print(" OK")
print()
print("=== 8. Terminal : Executer avec Multi-Send atteint tous les terminaux ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tabs = [LocalTerminalTabWidget() for _ in range(3)]
broadcast = BroadcastManager(lambda: tabs)
for t in tabs:
broadcast.register(t)
t.broadcast = broadcast # comme le ferait ConnectionController.new_local_terminal()
tabs[0]._execute_snippet(Snippet(name="test", command="uptime"), multi=True)
received = [t.connection._out_queue.get_nowait() for t in tabs]
print(" recu par chaque terminal:", received)
assert all(r == b"uptime\r" for r in received), received
for t in tabs:
t.close_connection()
print(" OK")
print()
print("=== 9. Menu Snippets : hierarchie categorie > snippet > actions, et declenchement ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab2 = LocalTerminalTabWidget()
tab2.snippet_store = SnippetStore(path=tmp / "snippets_menu.json")
tab2.snippet_store.snippets = [Snippet(name="Ping", category="Reseau", command="ping 1.1.1.1")]
tab2.broadcast = BroadcastManager(lambda: [tab2])
menu = tab2._build_snippets_menu() # ne fait qu'assembler le menu, ne l'affiche pas (pas de exec())
categories = [a.text() for a in menu.actions() if a.menu() is not None]
print(" categories dans le menu:", categories)
assert "Reseau" in categories
reseau_menu = next(a.menu() for a in menu.actions() if a.text() == "Reseau")
ping_menu = next(a.menu() for a in reseau_menu.actions() if a.text() == "Ping")
action_labels = [a.text() for a in ping_menu.actions()]
print(" actions du snippet:", action_labels)
assert action_labels == ["Inserer dans le terminal", "Executer", "Executer avec Multi-Send"]
insert_action = next(a for a in ping_menu.actions() if a.text() == "Inserer dans le terminal")
insert_action.trigger()
sent = tab2.connection._out_queue.get_nowait()
assert sent == b"ping 1.1.1.1", sent
print(" declenchement 'Inserer' -> commande envoyee sans Entree : OK")
tab2.close_connection()
print(" OK")
print()
print("SNIPPETS : TOUS LES TESTS OK")