From ff4a73e6e62cda5658b70fdc977849979a1bfa54 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 03:00:07 +0200 Subject: [PATCH] feat: ajouter la recherche rapide Ctrl+K (Quick Connect) Palette globale recherchant a travers sessions, hosts OpenSSH, workspaces, snippets et actions courantes, avec recherche floue et navigation entierement au clavier (fleches, Entree, Echap). - app/services/fuzzy.py : correspondance par sous-sequence (comme fzf, sans dependance externe), score favorisant les caracteres consecutifs et les debuts de mot. - QuickConnectDialog : collecte les resultats depuis les stores/controleurs deja existants (aucune duplication de logique - activer une session appelle ConnectionController.open_session(), un workspace WorkspaceManager.restore(), un snippet insert_snippet() dans l'onglet courant, sans jamais l'executer automatiquement). Les actions (Nouvelle session, Gestionnaires...) reutilisent les methodes des controleurs/dialogues existants. - Ctrl+K (menu Fichier > Recherche rapide...) ouvre la palette, reconstruite a chaque ouverture pour refleter l'etat courant. - insert_snippet()/execute_snippet() rendus publics sur BaseTerminalTab (utilises depuis un autre module par la palette). tests/test_fuzzy.py (scoring, tri par pertinence) et test_quick_connect.py (collecte des 4 types de resultats + actions, filtrage, navigation clavier, activation reelle d'une session et d'un snippet). Co-Authored-By: Claude Sonnet 5 --- app/dialogs/quick_connect_dialog.py | 191 ++++++++++++++++++++++++++++ app/main_window.py | 16 +++ app/services/fuzzy.py | 35 +++++ app/ui/tabs.py | 10 +- tests/test_fuzzy.py | 44 +++++++ tests/test_quick_connect.py | 143 +++++++++++++++++++++ tests/test_snippets.py | 6 +- 7 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 app/dialogs/quick_connect_dialog.py create mode 100644 app/services/fuzzy.py create mode 100644 tests/test_fuzzy.py create mode 100644 tests/test_quick_connect.py diff --git a/app/dialogs/quick_connect_dialog.py b/app/dialogs/quick_connect_dialog.py new file mode 100644 index 0000000..e73af6d --- /dev/null +++ b/app/dialogs/quick_connect_dialog.py @@ -0,0 +1,191 @@ +"""Palette de recherche globale (Ctrl+K) : sessions, hosts OpenSSH, workspaces, +snippets et actions, avec recherche floue et navigation entierement au clavier.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from PySide6.QtCore import Qt, QEvent +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, +) + +from app.openssh.config import load_openssh_hosts +from app.services.fuzzy import fuzzy_score + +ICONS = { + "session": "🖥", + "openssh": "⚙", + "workspace": "🗂", + "snippet": "📋", + "action": "▶", +} + +MAX_RESULTS = 60 + + +@dataclass +class PaletteResult: + kind: str + label: str + sublabel: str = "" + activate: Callable[[], None] = field(default=lambda: None) + + @property + def haystack(self) -> str: + return f"{self.label} {self.sublabel}" + + +class QuickConnectDialog(QDialog): + def __init__( + self, session_store, connection_controller, workspace_manager, snippet_store, + session_controller, key_manager, parent=None, + ): + super().__init__(parent) + self.session_store = session_store + self.cc = connection_controller + self.workspace_manager = workspace_manager + self.snippet_store = snippet_store + self.session_controller = session_controller + self.key_manager = key_manager + + self.setWindowTitle("Recherche rapide") + self.setMinimumSize(560, 420) + # pas de bouton "?" dans le titre, sans interet ici + self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) + + layout = QVBoxLayout(self) + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText("Rechercher une session, un host, un workspace, un snippet...") + layout.addWidget(self.search_edit) + + self.list = QListWidget() + layout.addWidget(self.list, 1) + + self.search_edit.installEventFilter(self) + self.search_edit.textChanged.connect(self._update_results) + self.list.itemActivated.connect(lambda _item: self._activate_current()) + + self._all_results: list[PaletteResult] = self._collect_results() + self._update_results() + self.search_edit.setFocus() + + # ---------- collecte des resultats ---------- + def _collect_results(self) -> list[PaletteResult]: + results: list[PaletteResult] = [] + + for session in self.session_store.sessions: + results.append(PaletteResult( + kind="session", label=session.name, + sublabel=f"{session.username}@{session.host}" + (f" ({session.group})" if session.group else ""), + activate=lambda s=session: self.cc.open_session(s), + )) + + try: + hosts = load_openssh_hosts() + except Exception: # noqa: BLE001 - ~/.ssh/config illisible : pas bloquant pour la palette + hosts = [] + for host in hosts: + results.append(PaletteResult( + kind="openssh", label=host.alias, sublabel=f"Host OpenSSH — {host.display_target}", + activate=lambda h=host: self.cc.open_openssh_host(h), + )) + + for workspace in self.workspace_manager.store.workspaces: + results.append(PaletteResult( + kind="workspace", label=workspace.name or "(sans nom)", + sublabel=f"Workspace — {len(workspace.tabs)} onglet(s)", + activate=lambda w=workspace: self.workspace_manager.restore(w), + )) + + for snippet in self.snippet_store.snippets: + results.append(PaletteResult( + kind="snippet", label=snippet.name, + sublabel=f"Snippet {snippet.category} — {snippet.command}".strip(), + activate=lambda s=snippet: self._insert_snippet_in_current_tab(s), + )) + + for label, action in self._quick_actions(): + results.append(PaletteResult(kind="action", label=label, sublabel="Action", activate=action)) + + return results + + def _quick_actions(self) -> list[tuple[str, Callable[[], None]]]: + from app.dialogs.workspace_dialog import WorkspaceDialog + from app.dialogs.snippet_dialog import SnippetManagerDialog + from app.dialogs.key_dialog import KeyManagerDialog + + return [ + ("Nouvelle session...", self.session_controller.new_session), + ("Connexion SSH rapide...", self.cc.quick_ssh), + ("Connexion SFTP rapide...", self.cc.quick_sftp), + ("Nouveau terminal local", self.cc.open_local_terminal), + ("Gestionnaire de workspaces...", + lambda: WorkspaceDialog(self.workspace_manager, parent=self.parent()).exec()), + ("Gestionnaire de snippets...", + lambda: SnippetManagerDialog(self.snippet_store, parent=self.parent()).exec()), + ("Gestionnaire de cles SSH...", + lambda: KeyManagerDialog(self.key_manager, parent=self.parent()).exec()), + ] + + def _insert_snippet_in_current_tab(self, snippet) -> None: + widget = self.cc.tabs.currentWidget() + if hasattr(widget, "insert_snippet"): + widget.insert_snippet(snippet) + else: + QMessageBox.information( + self.parent(), "Snippets", + "Ouvrez d'abord un terminal (ou un panneau) pour y inserer ce snippet.", + ) + + # ---------- filtrage / affichage ---------- + def _update_results(self) -> None: + query = self.search_edit.text() + self.list.clear() + scored = [] + for result in self._all_results: + score = fuzzy_score(query, result.haystack) + if score is not None: + scored.append((score, result)) + scored.sort(key=lambda pair: -pair[0]) + + for _score, result in scored[:MAX_RESULTS]: + icon = ICONS.get(result.kind, "") + item = QListWidgetItem(f"{icon} {result.label} — {result.sublabel}") + item.setData(Qt.UserRole, result) + self.list.addItem(item) + if self.list.count(): + self.list.setCurrentRow(0) + + # ---------- navigation clavier ---------- + def eventFilter(self, obj, event) -> bool: + if obj is self.search_edit and event.type() == QEvent.KeyPress: + key = event.key() + if key == Qt.Key_Down: + self._move_selection(1) + return True + if key == Qt.Key_Up: + self._move_selection(-1) + return True + if key in (Qt.Key_Return, Qt.Key_Enter): + self._activate_current() + return True + if key == Qt.Key_Escape: + self.reject() + return True + return super().eventFilter(obj, event) + + def _move_selection(self, delta: int) -> None: + if not self.list.count(): + return + row = max(0, min(self.list.count() - 1, self.list.currentRow() + delta)) + self.list.setCurrentRow(row) + + def _activate_current(self) -> None: + item = self.list.currentItem() + if not item: + return + result: PaletteResult = item.data(Qt.UserRole) + self.accept() + if result and result.activate: + result.activate() diff --git a/app/main_window.py b/app/main_window.py index 8964d69..59b8908 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -20,6 +20,7 @@ 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.dialogs.quick_connect_dialog import QuickConnectDialog from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme from app.workspace import WorkspaceStore from app.snippets import SnippetStore @@ -100,6 +101,14 @@ class MainWindow(QMainWindow): # ---------- construction UI ---------- def _build_menu(self) -> None: file_menu = self.menuBar().addMenu("&Fichier") + + quick_connect_action = QAction("Recherche rapide...", self) + quick_connect_action.setShortcut(QKeySequence("Ctrl+K")) + quick_connect_action.triggered.connect(self.open_quick_connect) + file_menu.addAction(quick_connect_action) + + file_menu.addSeparator() + new_session_action = QAction("Nouvelle session...", self) new_session_action.setShortcut(QKeySequence("Ctrl+N")) new_session_action.triggered.connect(self.session_controller.new_session) @@ -232,6 +241,13 @@ class MainWindow(QMainWindow): dialog = SnippetManagerDialog(self.snippet_store, parent=self) dialog.exec() + def open_quick_connect(self) -> None: + dialog = QuickConnectDialog( + self.session_store, self.connection_controller, self.workspace_manager, + self.snippet_store, self.session_controller, self.key_manager, parent=self, + ) + dialog.exec() + def show_about(self) -> None: QMessageBox.information( self, "A propos", diff --git a/app/services/fuzzy.py b/app/services/fuzzy.py new file mode 100644 index 0000000..80f65c1 --- /dev/null +++ b/app/services/fuzzy.py @@ -0,0 +1,35 @@ +"""Recherche floue minimale (sans dependance externe) : `query` doit apparaitre +comme sous-sequence de `text` (caracteres dans l'ordre, pas necessairement +contigus), avec un score favorisant les correspondances contigues et les +debuts de mot - le meme principe que fzf/Sublime Text en plus simple.""" +from __future__ import annotations + +_WORD_BOUNDARY = " -_./@:\\" + + +def fuzzy_score(query: str, text: str) -> int | None: + """None si `query` n'est pas une sous-sequence de `text`. Sinon un score + ou plus haut = meilleure correspondance.""" + if not query: + return 0 + q = query.lower() + t = text.lower() + score = 0 + pos = 0 + streak = 0 + for ch in q: + idx = t.find(ch, pos) + if idx == -1: + return None + if idx == pos: + streak += 1 + score += 3 + streak # caracteres consecutifs : bonus croissant + else: + streak = 0 + score += 1 + if idx == 0 or t[idx - 1] in _WORD_BOUNDARY: + score += 5 # debut de mot/segment + pos = idx + 1 + # penalise legerement les correspondances trop etalees dans un texte long + score += max(0, 20 - (pos - len(q))) + return score diff --git a/app/ui/tabs.py b/app/ui/tabs.py index 344b965..cdc977a 100644 --- a/app/ui/tabs.py +++ b/app/ui/tabs.py @@ -239,13 +239,13 @@ class BaseTerminalTab(QWidget): 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)) + 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)) + 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) + lambda checked=False, s=snippet: self.execute_snippet(s, multi=True) ) menu.addSeparator() manage_action = menu.addAction("Gerer les snippets...") @@ -257,12 +257,12 @@ class BaseTerminalTab(QWidget): return SnippetManagerDialog(self.snippet_store, parent=self).exec() - def _insert_snippet(self, snippet: Snippet) -> None: + 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: + 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) diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py new file mode 100644 index 0000000..73830a8 --- /dev/null +++ b/tests/test_fuzzy.py @@ -0,0 +1,44 @@ +"""Recherche floue (app.services.fuzzy) : sous-sequence, score, tri.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from app.services.fuzzy import fuzzy_score + +print("=== 1. Sous-sequence simple ===") +assert fuzzy_score("pve", "Proxmox Site A — root@192.168.10.10") is None # 'pve' n'est pas une sous-sequence +assert fuzzy_score("psa", "Proxmox Site A") is not None +assert fuzzy_score("xyz", "Proxmox") is None +print(" OK") + +print() +print("=== 2. Insensible a la casse ===") +assert fuzzy_score("PROXMOX", "proxmox-prod") is not None +assert fuzzy_score("proxmox", "PROXMOX-PROD") is not None +print(" OK") + +print() +print("=== 3. Chaine vide -> tout correspond, score neutre ===") +assert fuzzy_score("", "quoi que ce soit") == 0 +print(" OK") + +print() +print("=== 4. Une correspondance contigue en debut de mot est mieux classee ===") +better = fuzzy_score("prox", "proxmox-prod") # debut de chaine, contigu +worse = fuzzy_score("prox", "reverse-proxy-x") # eparpille +print(f" score contigu={better}, score eparpille={worse}") +assert better > worse +print(" OK") + +print() +print("=== 5. Tri d'une liste de candidats par pertinence ===") +candidates = ["reverse-proxy-eu", "proxmox-prod", "docker-proxy", "proxmox-lab"] +scored = sorted( + ((fuzzy_score("prox", c), c) for c in candidates), + key=lambda pair: -pair[0], +) +print(" ordre:", [c for _, c in scored]) +assert scored[0][1] in ("proxmox-prod", "proxmox-lab") +print(" OK") + +print() +print("FUZZY SEARCH : TOUS LES TESTS OK") diff --git a/tests/test_quick_connect.py b/tests/test_quick_connect.py new file mode 100644 index 0000000..9e96fee --- /dev/null +++ b/tests/test_quick_connect.py @@ -0,0 +1,143 @@ +"""Palette Quick Connect (Ctrl+K) : collecte des resultats (sessions, hosts +OpenSSH, workspaces, snippets, actions), filtrage flou, navigation clavier et +activation. + +SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees, et +~/.ssh/config est redirige vers un fichier absent (pas de dependance au poste +qui execute le test).""" +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.QtCore import Qt, QEvent +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QApplication, QWidget + +from app.session_manager import Session, SessionStore +from app.key_manager import KeyManager +from app.ssh_connection import SSHConnection +from app.local_terminal import LocalTerminalConnection +from app.controllers.broadcast_manager import BroadcastManager +from app.controllers.connection_controller import ConnectionController +from app.controllers.session_controller import SessionController +from app.controllers.workspace_manager import WorkspaceManager +from app.workspace import WorkspaceStore +from app.snippets import Snippet, SnippetStore +from app.dialogs.quick_connect_dialog import QuickConnectDialog +import app.app_settings as app_settings + +app = QApplication.instance() or QApplication([]) + +tmp = Path(tempfile.mkdtemp()) +absent_ssh_config = tmp / "no-such-ssh-config" + +no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None) +no_local_thread = patch.object(LocalTerminalConnection, "start", lambda self: None) +settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json") +no_ssh_thread.start() +no_local_thread.start() +settings_redirect.start() + + +def make_palette(): + session_store = SessionStore(path=tmp / "sessions.json") + key_manager = KeyManager(registry_path=tmp / "keys.json") + workspace_store = WorkspaceStore(path=tmp / "workspaces.json") + snippet_store = SnippetStore(path=tmp / "snippets.json") + snippet_store.snippets = [Snippet(name="Disk Usage", category="Linux", command="df -h")] + host_widget = QWidget() + broadcast = BroadcastManager(lambda: [host_widget]) + cc = ConnectionController(session_store, key_manager, broadcast, snippet_store=snippet_store, parent_widget=host_widget) + sc = SessionController(session_store, key_manager, broadcast.button, parent_widget=host_widget, + openssh_config_path=absent_ssh_config) + wm = WorkspaceManager(workspace_store, session_store, cc, parent_widget=host_widget) + dialog = QuickConnectDialog(session_store, cc, wm, snippet_store, sc, key_manager, parent=host_widget) + return session_store, cc, wm, snippet_store, dialog + + +print("=== 1. Collecte : sessions, workspaces, snippets et actions presents ===") +session_store, cc, wm, snippet_store, dialog = make_palette() +session = Session(name="prod-web", host="10.0.0.5", username="root") +session_store.add(session) +cc.open_local_terminal() +workspace = wm.save_as_new("Homelab") + +dialog._all_results = dialog._collect_results() # recollecte apres l'ajout de la session/workspace +kinds = {r.kind for r in dialog._all_results} +print(" types de resultats collectes:", kinds) +assert "session" in kinds +assert "workspace" in kinds +assert "snippet" in kinds +assert "action" in kinds +assert any(r.label == "prod-web" for r in dialog._all_results) +assert any(r.label == "Homelab" for r in dialog._all_results) +assert any(r.label == "Disk Usage" for r in dialog._all_results) +print(" OK") + +print() +print("=== 2. Filtrage : la recherche restreint et classe les resultats affiches ===") +dialog.search_edit.setText("prod") +print(f" {dialog.list.count()} resultat(s) pour 'prod'") +assert dialog.list.count() >= 1 +top_label = dialog.list.item(0).data(Qt.UserRole).label +print(" meilleur resultat:", top_label) +assert "prod" in top_label.lower() +print(" OK") + +print() +print("=== 3. Chaine vide -> tous les resultats affiches ===") +dialog.search_edit.setText("") +assert dialog.list.count() == len(dialog._all_results) +print(" OK") + +print() +print("=== 4. Navigation clavier : bas/haut deplacent la selection ===") +dialog.search_edit.setText("") +dialog.list.setCurrentRow(0) +down = QKeyEvent(QEvent.KeyPress, Qt.Key_Down, Qt.NoModifier) +dialog.eventFilter(dialog.search_edit, down) +assert dialog.list.currentRow() == 1 +up = QKeyEvent(QEvent.KeyPress, Qt.Key_Up, Qt.NoModifier) +dialog.eventFilter(dialog.search_edit, up) +assert dialog.list.currentRow() == 0 +print(" OK") + +print() +print("=== 5. Activation d'une session ouvre bien un onglet SSH ===") +session_store2, cc2, wm2, snippet_store2, dialog2 = make_palette() +session2 = Session(name="cible", host="10.0.0.9", username="root") +session_store2.add(session2) +dialog2._all_results = dialog2._collect_results() +dialog2.search_edit.setText("cible") +assert dialog2.list.count() == 1 +before = cc2.tabs.count() +dialog2._activate_current() +print(f" onglets avant/apres activation: {before}/{cc2.tabs.count()}") +assert cc2.tabs.count() == before + 1 +from app.ui.tabs import SessionTabWidget +assert isinstance(cc2.tabs.widget(cc2.tabs.count() - 1), SessionTabWidget) +print(" OK") + +print() +print("=== 6. Activation d'un snippet insere dans l'onglet courant ===") +session_store3, cc3, wm3, snippet_store3, dialog3 = make_palette() +cc3.open_local_terminal() +dialog3._all_results = dialog3._collect_results() +dialog3.search_edit.setText("Disk Usage") +assert dialog3.list.count() == 1 +dialog3._activate_current() +current_tab = cc3.tabs.currentWidget() +sent = current_tab.connection._out_queue.get_nowait() +print(" envoye dans l'onglet courant:", sent) +assert sent == b"df -h" +print(" OK") + +no_ssh_thread.stop() +no_local_thread.stop() +settings_redirect.stop() + +print() +print("QUICK CONNECT : TOUS LES TESTS OK") diff --git a/tests/test_snippets.py b/tests/test_snippets.py index 6f04f15..d08edce 100644 --- a/tests/test_snippets.py +++ b/tests/test_snippets.py @@ -80,7 +80,7 @@ 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) + tab.insert_snippet(snippet) sent = tab.connection._out_queue.get_nowait() print(" envoye:", sent) assert sent == b"echo bonjour" @@ -92,7 +92,7 @@ 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")) + 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" @@ -108,7 +108,7 @@ with patch.object(LocalTerminalConnection, "start", lambda self: None): broadcast.register(t) t.broadcast = broadcast # comme le ferait ConnectionController.new_local_terminal() - tabs[0]._execute_snippet(Snippet(name="test", command="uptime"), multi=True) + 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