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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist/
|
|||||||
data/sessions.json*
|
data/sessions.json*
|
||||||
data/keys.json*
|
data/keys.json*
|
||||||
data/workspaces.json*
|
data/workspaces.json*
|
||||||
|
data/snippets.json*
|
||||||
data/known_hosts*
|
data/known_hosts*
|
||||||
data/keys/*
|
data/keys/*
|
||||||
!data/keys/.gitkeep
|
!data/keys/.gitkeep
|
||||||
|
|||||||
@@ -35,11 +35,20 @@ class BroadcastManager(QObject):
|
|||||||
def _broadcast(self, data: bytes, source) -> None:
|
def _broadcast(self, data: bytes, source) -> None:
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return
|
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)
|
# import tardif : evite un cycle (app.ui.tabs importe des dialogues, pas ce module)
|
||||||
from app.ui.tabs import BaseTerminalTab
|
from app.ui.tabs import BaseTerminalTab
|
||||||
|
|
||||||
for tab in self._tabs_provider():
|
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
|
continue
|
||||||
if tab.connection:
|
if tab.connection:
|
||||||
# on ecrit directement dans la connexion : passer par le terminal
|
# on ecrit directement dans la connexion : passer par le terminal
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ class ConnectionController(QObject):
|
|||||||
session_store: SessionStore,
|
session_store: SessionStore,
|
||||||
key_manager: KeyManager,
|
key_manager: KeyManager,
|
||||||
broadcast: BroadcastManager,
|
broadcast: BroadcastManager,
|
||||||
|
snippet_store=None,
|
||||||
parent_widget: QWidget | None = None,
|
parent_widget: QWidget | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(parent_widget)
|
super().__init__(parent_widget)
|
||||||
self.session_store = session_store
|
self.session_store = session_store
|
||||||
self.key_manager = key_manager
|
self.key_manager = key_manager
|
||||||
self.broadcast = broadcast
|
self.broadcast = broadcast
|
||||||
|
self.snippet_store = snippet_store
|
||||||
self._parent_widget = parent_widget
|
self._parent_widget = parent_widget
|
||||||
|
|
||||||
self.tabs = QTabWidget(parent_widget)
|
self.tabs = QTabWidget(parent_widget)
|
||||||
@@ -85,7 +87,10 @@ class ConnectionController(QObject):
|
|||||||
ok, secret = self._ask_session_password(session)
|
ok, secret = self._ask_session_password(session)
|
||||||
if not ok:
|
if not ok:
|
||||||
return None
|
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:
|
def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None:
|
||||||
ok, bastion_target, bastion_password = self._resolve_bastion(session)
|
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:
|
def new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget:
|
||||||
"""Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter
|
"""Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter
|
||||||
au QTabWidget : reutilise pour un onglet normal comme pour un panneau."""
|
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)
|
tab.sftp_requested.connect(self.quick_sftp)
|
||||||
self.broadcast.register(tab)
|
self.broadcast.register(tab)
|
||||||
return tab
|
return tab
|
||||||
|
|||||||
170
app/dialogs/snippet_dialog.py
Normal file
170
app/dialogs/snippet_dialog.py
Normal 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()
|
||||||
@@ -19,8 +19,10 @@ from app.terminal_widget import TerminalWidget
|
|||||||
from app.dialogs.key_dialog import KeyManagerDialog
|
from app.dialogs.key_dialog import KeyManagerDialog
|
||||||
from app.dialogs.known_hosts_dialog import KnownHostsDialog
|
from app.dialogs.known_hosts_dialog import KnownHostsDialog
|
||||||
from app.dialogs.workspace_dialog import WorkspaceDialog
|
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.theme import THEME_LABELS, apply_theme, current_theme, save_theme
|
||||||
from app.workspace import WorkspaceStore
|
from app.workspace import WorkspaceStore
|
||||||
|
from app.snippets import SnippetStore
|
||||||
from app.controllers.broadcast_manager import BroadcastManager
|
from app.controllers.broadcast_manager import BroadcastManager
|
||||||
from app.controllers.session_controller import SessionController
|
from app.controllers.session_controller import SessionController
|
||||||
from app.controllers.connection_controller import ConnectionController
|
from app.controllers.connection_controller import ConnectionController
|
||||||
@@ -40,10 +42,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.session_store = SessionStore()
|
self.session_store = SessionStore()
|
||||||
self.key_manager = KeyManager()
|
self.key_manager = KeyManager()
|
||||||
self.workspace_store = WorkspaceStore()
|
self.workspace_store = WorkspaceStore()
|
||||||
|
self.snippet_store = SnippetStore()
|
||||||
|
|
||||||
self.broadcast = BroadcastManager(self._iter_tabs, self)
|
self.broadcast = BroadcastManager(self._iter_tabs, self)
|
||||||
self.connection_controller = ConnectionController(
|
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_controller = SessionController(
|
||||||
self.session_store, self.key_manager, self.broadcast.button, parent_widget=self,
|
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)
|
manage_workspaces_action.triggered.connect(self.open_workspace_manager)
|
||||||
workspaces_menu.addAction(manage_workspaces_action)
|
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")
|
help_menu = self.menuBar().addMenu("&Aide")
|
||||||
about_action = QAction("A propos", self)
|
about_action = QAction("A propos", self)
|
||||||
about_action.triggered.connect(self.show_about)
|
about_action.triggered.connect(self.show_about)
|
||||||
@@ -219,6 +228,10 @@ class MainWindow(QMainWindow):
|
|||||||
dialog = WorkspaceDialog(self.workspace_manager, parent=self)
|
dialog = WorkspaceDialog(self.workspace_manager, parent=self)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
||||||
|
def open_snippet_manager(self) -> None:
|
||||||
|
dialog = SnippetManagerDialog(self.snippet_store, parent=self)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
def show_about(self) -> None:
|
def show_about(self) -> None:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "A propos",
|
self, "A propos",
|
||||||
|
|||||||
118
app/snippets.py
Normal file
118
app/snippets.py
Normal 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()
|
||||||
|
]
|
||||||
@@ -15,7 +15,7 @@ from PySide6.QtCore import Qt, Signal, QTimer
|
|||||||
from PySide6.QtGui import QKeySequence, QShortcut
|
from PySide6.QtGui import QKeySequence, QShortcut
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter,
|
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter,
|
||||||
QFrame, QLineEdit, QFileDialog,
|
QFrame, QLineEdit, QFileDialog, QMenu,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.session_manager import Session, AUTH_KEY, AUTH_PASSWORD
|
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.terminal_widget import TerminalWidget
|
||||||
from app.sftp_panel import SFTPPanel
|
from app.sftp_panel import SFTPPanel
|
||||||
from app.dialogs.tunnel_dialog import TunnelDialog
|
from app.dialogs.tunnel_dialog import TunnelDialog
|
||||||
|
from app.dialogs.snippet_dialog import SnippetManagerDialog
|
||||||
from app.tunnels import TunnelManager
|
from app.tunnels import TunnelManager
|
||||||
from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change
|
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.ssh_target import detect_ssh_target
|
||||||
from app.services.reconnect import compute_backoff_delay
|
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
|
# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
|
||||||
ANSI_ESCAPE_RE = re.compile(
|
ANSI_ESCAPE_RE = re.compile(
|
||||||
@@ -44,8 +46,14 @@ class BaseTerminalTab(QWidget):
|
|||||||
connexion (SSH ou locale) qui expose la meme interface de signaux
|
connexion (SSH ou locale) qui expose la meme interface de signaux
|
||||||
(connected / data_received / disconnected / error / send / resize)."""
|
(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)
|
super().__init__(parent)
|
||||||
|
self.snippet_store = snippet_store
|
||||||
|
self.broadcast = broadcast
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
@@ -65,6 +73,15 @@ class BaseTerminalTab(QWidget):
|
|||||||
self.reconnect_btn.clicked.connect(self.reconnect)
|
self.reconnect_btn.clicked.connect(self.reconnect)
|
||||||
self.bar.addWidget(self.reconnect_btn)
|
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 = QToolButton()
|
||||||
self.log_btn.setText("⏺ Journal")
|
self.log_btn.setText("⏺ Journal")
|
||||||
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
|
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
|
||||||
@@ -204,6 +221,54 @@ class BaseTerminalTab(QWidget):
|
|||||||
self.reconnect_btn.show()
|
self.reconnect_btn.show()
|
||||||
QMessageBox.critical(self, "Erreur", message)
|
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 ----------
|
# ---------- recherche dans l'historique ----------
|
||||||
def open_search(self) -> None:
|
def open_search(self) -> None:
|
||||||
self.search_bar.show()
|
self.search_bar.show()
|
||||||
@@ -235,9 +300,12 @@ class SessionTabWidget(BaseTerminalTab):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
|
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.session = session
|
||||||
self._bastion_target = bastion_target
|
self._bastion_target = bastion_target
|
||||||
self._bastion_password = bastion_password
|
self._bastion_password = bastion_password
|
||||||
@@ -526,8 +594,11 @@ class LocalTerminalTabWidget(BaseTerminalTab):
|
|||||||
|
|
||||||
sftp_requested = Signal(object) # cible ssh detectee (ou None)
|
sftp_requested = Signal(object) # cible ssh detectee (ou None)
|
||||||
|
|
||||||
def __init__(self, shell: str = SHELL_POWERSHELL, parent=None):
|
def __init__(self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None, parent=None):
|
||||||
super().__init__(f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...", parent)
|
super().__init__(
|
||||||
|
f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...",
|
||||||
|
snippet_store=snippet_store, broadcast=broadcast, parent=parent,
|
||||||
|
)
|
||||||
self.shell = shell
|
self.shell = shell
|
||||||
|
|
||||||
# Meme dans un terminal local, on tape souvent "ssh user@ip" a la main :
|
# Meme dans un terminal local, on tape souvent "ssh user@ip" a la main :
|
||||||
|
|||||||
148
tests/test_snippets.py
Normal file
148
tests/test_snippets.py
Normal 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")
|
||||||
Reference in New Issue
Block a user