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:
Florian
2026-09-05 03:00:07 +02:00
parent 0a07ddcb62
commit ff4a73e6e6
7 changed files with 437 additions and 8 deletions

View 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()

View File

@@ -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",

35
app/services/fuzzy.py Normal file
View 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

View File

@@ -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)