From 0a07ddcb62b6544c42082e4eb660e860d673de71 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 02:55:10 +0200 Subject: [PATCH] feat: ajouter les Snippets de commandes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + app/controllers/broadcast_manager.py | 11 +- app/controllers/connection_controller.py | 9 +- app/dialogs/snippet_dialog.py | 170 +++++++++++++++++++++++ app/main_window.py | 15 +- app/snippets.py | 118 ++++++++++++++++ app/ui/tabs.py | 83 ++++++++++- tests/test_snippets.py | 148 ++++++++++++++++++++ 8 files changed, 545 insertions(+), 10 deletions(-) create mode 100644 app/dialogs/snippet_dialog.py create mode 100644 app/snippets.py create mode 100644 tests/test_snippets.py 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")