diff --git a/.gitignore b/.gitignore index 9b7eb69..0050526 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/controllers/broadcast_manager.py b/app/controllers/broadcast_manager.py index 9173f52..1830626 100644 --- a/app/controllers/broadcast_manager.py +++ b/app/controllers/broadcast_manager.py @@ -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 diff --git a/app/controllers/connection_controller.py b/app/controllers/connection_controller.py index f420652..676f054 100644 --- a/app/controllers/connection_controller.py +++ b/app/controllers/connection_controller.py @@ -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 diff --git a/app/dialogs/snippet_dialog.py b/app/dialogs/snippet_dialog.py new file mode 100644 index 0000000..dbd3961 --- /dev/null +++ b/app/dialogs/snippet_dialog.py @@ -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() diff --git a/app/main_window.py b/app/main_window.py index 759ec42..8964d69 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -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", diff --git a/app/snippets.py b/app/snippets.py new file mode 100644 index 0000000..2f329dd --- /dev/null +++ b/app/snippets.py @@ -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() + ] diff --git a/app/ui/tabs.py b/app/ui/tabs.py index 9666661..344b965 100644 --- a/app/ui/tabs.py +++ b/app/ui/tabs.py @@ -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 : diff --git a/tests/test_snippets.py b/tests/test_snippets.py new file mode 100644 index 0000000..6f04f15 --- /dev/null +++ b/tests/test_snippets.py @@ -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")