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 <noreply@anthropic.com>
This commit is contained in:
191
app/dialogs/quick_connect_dialog.py
Normal file
191
app/dialogs/quick_connect_dialog.py
Normal file
@@ -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()
|
||||||
@@ -20,6 +20,7 @@ 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.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.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.snippets import SnippetStore
|
||||||
@@ -100,6 +101,14 @@ class MainWindow(QMainWindow):
|
|||||||
# ---------- construction UI ----------
|
# ---------- construction UI ----------
|
||||||
def _build_menu(self) -> None:
|
def _build_menu(self) -> None:
|
||||||
file_menu = self.menuBar().addMenu("&Fichier")
|
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 = QAction("Nouvelle session...", self)
|
||||||
new_session_action.setShortcut(QKeySequence("Ctrl+N"))
|
new_session_action.setShortcut(QKeySequence("Ctrl+N"))
|
||||||
new_session_action.triggered.connect(self.session_controller.new_session)
|
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 = SnippetManagerDialog(self.snippet_store, parent=self)
|
||||||
dialog.exec()
|
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:
|
def show_about(self) -> None:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "A propos",
|
self, "A propos",
|
||||||
|
|||||||
35
app/services/fuzzy.py
Normal file
35
app/services/fuzzy.py
Normal file
@@ -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
|
||||||
@@ -239,13 +239,13 @@ class BaseTerminalTab(QWidget):
|
|||||||
if snippet.description:
|
if snippet.description:
|
||||||
snippet_menu.setToolTip(snippet.description)
|
snippet_menu.setToolTip(snippet.description)
|
||||||
insert_action = snippet_menu.addAction("Inserer dans le terminal")
|
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 = 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:
|
if self.broadcast is not None:
|
||||||
multi_action = snippet_menu.addAction("Executer avec Multi-Send")
|
multi_action = snippet_menu.addAction("Executer avec Multi-Send")
|
||||||
multi_action.triggered.connect(
|
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()
|
menu.addSeparator()
|
||||||
manage_action = menu.addAction("Gerer les snippets...")
|
manage_action = menu.addAction("Gerer les snippets...")
|
||||||
@@ -257,12 +257,12 @@ class BaseTerminalTab(QWidget):
|
|||||||
return
|
return
|
||||||
SnippetManagerDialog(self.snippet_store, parent=self).exec()
|
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."""
|
"""N'envoie jamais Entree : le texte est ecrit, a l'utilisateur de valider."""
|
||||||
if self.connection:
|
if self.connection:
|
||||||
self.connection.send(snippet.command.encode("utf-8"))
|
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"
|
data = snippet.command.encode("utf-8") + b"\r"
|
||||||
if multi and self.broadcast is not None:
|
if multi and self.broadcast is not None:
|
||||||
self.broadcast.broadcast_now(data)
|
self.broadcast.broadcast_now(data)
|
||||||
|
|||||||
44
tests/test_fuzzy.py
Normal file
44
tests/test_fuzzy.py
Normal file
@@ -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")
|
||||||
143
tests/test_quick_connect.py
Normal file
143
tests/test_quick_connect.py
Normal file
@@ -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")
|
||||||
@@ -80,7 +80,7 @@ print("=== 6. Terminal : Inserer n'envoie jamais Entree ===")
|
|||||||
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
||||||
tab = LocalTerminalTabWidget()
|
tab = LocalTerminalTabWidget()
|
||||||
snippet = Snippet(name="test", command="echo bonjour")
|
snippet = Snippet(name="test", command="echo bonjour")
|
||||||
tab._insert_snippet(snippet)
|
tab.insert_snippet(snippet)
|
||||||
sent = tab.connection._out_queue.get_nowait()
|
sent = tab.connection._out_queue.get_nowait()
|
||||||
print(" envoye:", sent)
|
print(" envoye:", sent)
|
||||||
assert sent == b"echo bonjour"
|
assert sent == b"echo bonjour"
|
||||||
@@ -92,7 +92,7 @@ print()
|
|||||||
print("=== 7. Terminal : Executer envoie la commande suivie d'Entree ===")
|
print("=== 7. Terminal : Executer envoie la commande suivie d'Entree ===")
|
||||||
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
||||||
tab = LocalTerminalTabWidget()
|
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()
|
sent = tab.connection._out_queue.get_nowait()
|
||||||
print(" envoye:", sent)
|
print(" envoye:", sent)
|
||||||
assert sent == b"echo bonjour\r"
|
assert sent == b"echo bonjour\r"
|
||||||
@@ -108,7 +108,7 @@ with patch.object(LocalTerminalConnection, "start", lambda self: None):
|
|||||||
broadcast.register(t)
|
broadcast.register(t)
|
||||||
t.broadcast = broadcast # comme le ferait ConnectionController.new_local_terminal()
|
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]
|
received = [t.connection._out_queue.get_nowait() for t in tabs]
|
||||||
print(" recu par chaque terminal:", received)
|
print(" recu par chaque terminal:", received)
|
||||||
assert all(r == b"uptime\r" for r in received), received
|
assert all(r == b"uptime\r" for r in received), received
|
||||||
|
|||||||
Reference in New Issue
Block a user