pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".
ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
- app/ssh_connection.py : `raise ... from None` pour le refus explicite de
passphrase (ce n'est pas un echec a diagnostiquer).
- app/controllers/workspace_manager.py : zip() sans strict= documente en
commentaire (deliberement tolerant a un workspace legerement perime).
- tests/test_ai_redaction.py : nom de variable ambigu renomme.
- E702 (construction compacte "widget = X(); widget.setY(...)" dans
sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
symbole juste avant la section qui l'utilise) : ignores explicitement, ce
sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.
pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.
.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.
Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
197 lines
7.2 KiB
Python
197 lines
7.2 KiB
Python
"""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 collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
|
|
from PySide6.QtCore import QEvent, Qt
|
|
from PySide6.QtWidgets import (
|
|
QDialog,
|
|
QLineEdit,
|
|
QListWidget,
|
|
QListWidgetItem,
|
|
QMessageBox,
|
|
QVBoxLayout,
|
|
)
|
|
|
|
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.key_dialog import KeyManagerDialog
|
|
from app.dialogs.snippet_dialog import SnippetManagerDialog
|
|
from app.dialogs.workspace_dialog import WorkspaceDialog
|
|
|
|
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()
|