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>
391 lines
16 KiB
Python
391 lines
16 KiB
Python
"""Panneau lateral : arbre des sessions SSH enregistrees + terminaux locaux, et
|
|
leur CRUD (creer / modifier / dupliquer / supprimer). N'ouvre aucune connexion
|
|
elle-meme : elle emet des signaux que ConnectionController ecoute."""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import uuid
|
|
|
|
from PySide6.QtCore import QObject, Qt, Signal
|
|
from PySide6.QtWidgets import (
|
|
QComboBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QMenu,
|
|
QMessageBox,
|
|
QToolButton,
|
|
QTreeWidget,
|
|
QTreeWidgetItem,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
|
|
from app.dialogs.group_settings_dialog import GroupSettingsDialog
|
|
from app.dialogs.session_dialog import SessionDialog
|
|
from app.groups import GroupSettingsStore
|
|
from app.key_manager import KeyManager
|
|
from app.local_terminal import SHELL_LABELS
|
|
from app.openssh.config import (
|
|
OpenSSHHost,
|
|
default_config_path,
|
|
load_openssh_hosts,
|
|
session_from_openssh_host,
|
|
)
|
|
from app.session_manager import Session, SessionStore
|
|
|
|
SESSION_ROLE = Qt.UserRole + 10
|
|
LOCAL_SHELL_ROLE = Qt.UserRole + 11
|
|
OPENSSH_HOST_ROLE = Qt.UserRole + 12
|
|
GROUP_PATH_ROLE = Qt.UserRole + 13
|
|
|
|
|
|
class SessionController(QObject):
|
|
open_session_requested = Signal(object) # Session
|
|
open_sftp_requested = Signal(object) # Session
|
|
open_local_shell_requested = Signal(str) # identifiant de shell
|
|
open_openssh_host_requested = Signal(object) # OpenSSHHost
|
|
quick_sftp_requested = Signal()
|
|
manage_keys_requested = Signal()
|
|
manage_known_hosts_requested = Signal()
|
|
|
|
def __init__(
|
|
self,
|
|
session_store: SessionStore,
|
|
key_manager: KeyManager,
|
|
broadcast_button: QToolButton,
|
|
parent_widget: QWidget | None = None,
|
|
openssh_config_path=None,
|
|
group_store: GroupSettingsStore | None = None,
|
|
credential_store: CredentialStore | None = None,
|
|
):
|
|
super().__init__(parent_widget)
|
|
self.session_store = session_store
|
|
self.key_manager = key_manager
|
|
self.group_store = group_store
|
|
self.credential_store = credential_store or NullCredentialStore()
|
|
self._parent_widget = parent_widget
|
|
self._openssh_config_path = openssh_config_path or default_config_path()
|
|
|
|
self.widget = QWidget(parent_widget)
|
|
layout = QVBoxLayout(self.widget)
|
|
layout.setContentsMargins(6, 6, 6, 6)
|
|
layout.setSpacing(6)
|
|
|
|
toolbar = QHBoxLayout()
|
|
new_btn = QToolButton()
|
|
new_btn.setObjectName("primary")
|
|
new_btn.setText("+ Session")
|
|
new_btn.clicked.connect(lambda: self.new_session())
|
|
toolbar.addWidget(new_btn)
|
|
|
|
term_btn = QToolButton()
|
|
term_btn.setText("💻 Terminal")
|
|
term_btn.setPopupMode(QToolButton.InstantPopup)
|
|
term_menu = QMenu(term_btn)
|
|
for shell_id, label in SHELL_LABELS.items():
|
|
term_menu.addAction(label, lambda s=shell_id: self.open_local_shell_requested.emit(s))
|
|
term_btn.setMenu(term_menu)
|
|
toolbar.addWidget(term_btn)
|
|
|
|
toolbar.addWidget(broadcast_button)
|
|
|
|
sftp_btn = QToolButton()
|
|
sftp_btn.setText("📁 SFTP")
|
|
sftp_btn.setToolTip("Ouvrir un gestionnaire de fichiers SFTP (sans session enregistree)")
|
|
sftp_btn.clicked.connect(self.quick_sftp_requested.emit)
|
|
toolbar.addWidget(sftp_btn)
|
|
|
|
toolbar.addStretch()
|
|
|
|
manage_btn = QToolButton()
|
|
manage_btn.setText("⚙")
|
|
manage_btn.setToolTip("Cles SSH et hotes connus")
|
|
manage_btn.setPopupMode(QToolButton.InstantPopup)
|
|
manage_menu = QMenu(manage_btn)
|
|
manage_menu.addAction("Gestionnaire de cles SSH...", self.manage_keys_requested.emit)
|
|
manage_menu.addAction("Gestionnaire des hotes connus...", self.manage_known_hosts_requested.emit)
|
|
manage_menu.addSeparator()
|
|
manage_menu.addAction("Rafraichir la configuration OpenSSH (~/.ssh/config)", self.reload)
|
|
manage_btn.setMenu(manage_menu)
|
|
toolbar.addWidget(manage_btn)
|
|
|
|
layout.addLayout(toolbar)
|
|
|
|
filter_row = QHBoxLayout()
|
|
filter_row.addWidget(QLabel("🏷"))
|
|
self.tag_filter_combo = QComboBox()
|
|
self.tag_filter_combo.setEditable(True)
|
|
self.tag_filter_combo.setInsertPolicy(QComboBox.NoInsert)
|
|
self.tag_filter_combo.lineEdit().setPlaceholderText("Filtrer les sessions par tag...")
|
|
self.tag_filter_combo.currentTextChanged.connect(self._apply_tag_filter)
|
|
filter_row.addWidget(self.tag_filter_combo, 1)
|
|
layout.addLayout(filter_row)
|
|
|
|
self.tree = QTreeWidget()
|
|
self.tree.setHeaderHidden(True)
|
|
self.tree.itemDoubleClicked.connect(self._on_tree_double_click)
|
|
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.tree.customContextMenuRequested.connect(self._on_tree_context_menu)
|
|
layout.addWidget(self.tree)
|
|
|
|
# Sans minimum explicite, la largeur plancher est imposee par le contenu
|
|
# (les noms de sessions), ce qui empeche de retrecir vraiment le panneau.
|
|
self.widget.setMinimumWidth(120)
|
|
self.tree.setMinimumWidth(0)
|
|
|
|
self.reload()
|
|
|
|
# ---------- arbre ----------
|
|
def reload(self) -> None:
|
|
self.tree.clear()
|
|
|
|
locals_header = QTreeWidgetItem(["💻 Terminaux locaux"])
|
|
locals_header.setFlags(locals_header.flags() & ~Qt.ItemIsSelectable)
|
|
font = locals_header.font(0)
|
|
font.setBold(True)
|
|
locals_header.setFont(0, font)
|
|
self.tree.addTopLevelItem(locals_header)
|
|
for shell, label in SHELL_LABELS.items():
|
|
child = QTreeWidgetItem([f" {label}"])
|
|
child.setData(0, LOCAL_SHELL_ROLE, shell)
|
|
locals_header.addChild(child)
|
|
|
|
sessions_header = QTreeWidgetItem(["🔐 Sessions SSH"])
|
|
sessions_header.setFlags(sessions_header.flags() & ~Qt.ItemIsSelectable)
|
|
sessions_header.setFont(0, font)
|
|
self.tree.addTopLevelItem(sessions_header)
|
|
|
|
folder_items: dict[str, QTreeWidgetItem] = {}
|
|
|
|
def get_folder(path: str) -> QTreeWidgetItem:
|
|
if not path:
|
|
return sessions_header
|
|
if path in folder_items:
|
|
return folder_items[path]
|
|
parent_path, _, name = path.rpartition("/")
|
|
parent_item = get_folder(parent_path)
|
|
item = QTreeWidgetItem([f"📁 {name}"])
|
|
item.setFlags(item.flags() & ~Qt.ItemIsSelectable)
|
|
item.setData(0, GROUP_PATH_ROLE, path)
|
|
parent_item.addChild(item)
|
|
folder_items[path] = item
|
|
return item
|
|
|
|
for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
|
|
parent = get_folder(session.group)
|
|
label = f"🖥 {session.name} ({session.host})"
|
|
if session.tags:
|
|
label += " [" + ", ".join(session.tags) + "]"
|
|
item = QTreeWidgetItem([label])
|
|
item.setData(0, SESSION_ROLE, session.id)
|
|
parent.addChild(item)
|
|
|
|
if not self.session_store.sessions:
|
|
hint = QTreeWidgetItem([" (aucune session — bouton « + Session » ou « 📁 SFTP »)"])
|
|
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
|
|
hint.setForeground(0, Qt.gray)
|
|
sessions_header.addChild(hint)
|
|
|
|
openssh_header = QTreeWidgetItem(["⚙ Configuration OpenSSH"])
|
|
openssh_header.setFlags(openssh_header.flags() & ~Qt.ItemIsSelectable)
|
|
openssh_header.setFont(0, font)
|
|
openssh_header.setToolTip(0, str(self._openssh_config_path))
|
|
self.tree.addTopLevelItem(openssh_header)
|
|
|
|
hosts = load_openssh_hosts(self._openssh_config_path)
|
|
for host in hosts:
|
|
label = f"🖥 {host.alias} ({host.display_target})"
|
|
if host.proxy_jump:
|
|
label += f" · via {host.proxy_jump}"
|
|
item = QTreeWidgetItem([label])
|
|
item.setData(0, OPENSSH_HOST_ROLE, host)
|
|
if host.proxy_jump:
|
|
item.setToolTip(0, f"ProxyJump : via {host.proxy_jump}")
|
|
openssh_header.addChild(item)
|
|
|
|
if not hosts:
|
|
hint = QTreeWidgetItem([f" (aucun host trouve dans {self._openssh_config_path})"])
|
|
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
|
|
hint.setForeground(0, Qt.gray)
|
|
openssh_header.addChild(hint)
|
|
|
|
self.tree.expandAll()
|
|
self._reload_tag_filter()
|
|
self._apply_tag_filter()
|
|
|
|
def _reload_tag_filter(self) -> None:
|
|
current = self.tag_filter_combo.currentText()
|
|
self.tag_filter_combo.blockSignals(True)
|
|
self.tag_filter_combo.clear()
|
|
self.tag_filter_combo.addItem("") # aucun filtre
|
|
self.tag_filter_combo.addItems(self.session_store.tags())
|
|
self.tag_filter_combo.setCurrentText(current)
|
|
self.tag_filter_combo.blockSignals(False)
|
|
|
|
def _apply_tag_filter(self, _text: str = "") -> None:
|
|
query = self.tag_filter_combo.currentText().strip().lower()
|
|
|
|
def visit(item: QTreeWidgetItem) -> None:
|
|
session = self._session_from_item(item)
|
|
if session is not None:
|
|
item.setHidden(bool(query) and not any(query in t.lower() for t in session.tags))
|
|
for i in range(item.childCount()):
|
|
visit(item.child(i))
|
|
|
|
for i in range(self.tree.topLevelItemCount()):
|
|
visit(self.tree.topLevelItem(i))
|
|
|
|
def _session_from_item(self, item: QTreeWidgetItem) -> Session | None:
|
|
if not item:
|
|
return None
|
|
session_id = item.data(0, SESSION_ROLE)
|
|
if not session_id:
|
|
return None
|
|
return self.session_store.get(session_id)
|
|
|
|
def _shell_from_item(self, item: QTreeWidgetItem) -> str | None:
|
|
if not item:
|
|
return None
|
|
return item.data(0, LOCAL_SHELL_ROLE)
|
|
|
|
def _openssh_host_from_item(self, item: QTreeWidgetItem) -> OpenSSHHost | None:
|
|
if not item:
|
|
return None
|
|
return item.data(0, OPENSSH_HOST_ROLE)
|
|
|
|
def _group_from_item(self, item: QTreeWidgetItem) -> str | None:
|
|
if not item:
|
|
return None
|
|
return item.data(0, GROUP_PATH_ROLE)
|
|
|
|
def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None:
|
|
session = self._session_from_item(item)
|
|
if session:
|
|
self.open_session_requested.emit(session)
|
|
return
|
|
shell = self._shell_from_item(item)
|
|
if shell:
|
|
self.open_local_shell_requested.emit(shell)
|
|
return
|
|
host = self._openssh_host_from_item(item)
|
|
if host:
|
|
self.open_openssh_host_requested.emit(host)
|
|
|
|
def _on_tree_context_menu(self, pos) -> None:
|
|
item = self.tree.itemAt(pos)
|
|
menu = QMenu(self.widget)
|
|
session = self._session_from_item(item) if item else None
|
|
shell = self._shell_from_item(item) if item else None
|
|
openssh_host = self._openssh_host_from_item(item) if item else None
|
|
group_path = self._group_from_item(item) if item else None
|
|
|
|
if session:
|
|
connect_action = menu.addAction("🖥 Ouvrir un terminal SSH")
|
|
sftp_action = menu.addAction("📁 Ouvrir en SFTP (gestionnaire de fichiers)")
|
|
menu.addSeparator()
|
|
edit_action = menu.addAction("Modifier...")
|
|
duplicate_action = menu.addAction("Dupliquer")
|
|
delete_action = menu.addAction("Supprimer")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == connect_action:
|
|
self.open_session_requested.emit(session)
|
|
elif action == sftp_action:
|
|
self.open_sftp_requested.emit(session)
|
|
elif action == edit_action:
|
|
self.edit_session(session)
|
|
elif action == duplicate_action:
|
|
self.duplicate_session(session)
|
|
elif action == delete_action:
|
|
self.delete_session(session)
|
|
elif shell:
|
|
open_action = menu.addAction("Ouvrir")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == open_action:
|
|
self.open_local_shell_requested.emit(shell)
|
|
elif openssh_host:
|
|
connect_action = menu.addAction("🖥 Se connecter")
|
|
import_action = menu.addAction("Importer comme session PySSH...")
|
|
menu.addSeparator()
|
|
refresh_action = menu.addAction("🔄 Rafraichir la configuration")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == connect_action:
|
|
self.open_openssh_host_requested.emit(openssh_host)
|
|
elif action == import_action:
|
|
self.import_openssh_host(openssh_host)
|
|
elif action == refresh_action:
|
|
self.reload()
|
|
elif group_path:
|
|
new_in_group_action = menu.addAction("Nouvelle session dans ce dossier...")
|
|
menu.addSeparator()
|
|
group_settings_action = menu.addAction("Parametres du groupe...")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == new_in_group_action:
|
|
self.new_session(group=group_path)
|
|
elif action == group_settings_action:
|
|
self.open_group_settings(group_path)
|
|
else:
|
|
new_action = menu.addAction("Nouvelle session...")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == new_action:
|
|
self.new_session()
|
|
|
|
# ---------- CRUD ----------
|
|
def new_session(self, group: str = "") -> None:
|
|
prefill = Session(group=group) if group else None
|
|
dialog = SessionDialog(
|
|
self.key_manager, session=prefill, parent=self._parent_widget,
|
|
session_store=self.session_store, group_store=self.group_store,
|
|
credential_store=self.credential_store,
|
|
)
|
|
if dialog.exec():
|
|
self.session_store.add(dialog.result_session())
|
|
self.reload()
|
|
|
|
def edit_session(self, session: Session) -> None:
|
|
dialog = SessionDialog(
|
|
self.key_manager, session=session, parent=self._parent_widget,
|
|
session_store=self.session_store, group_store=self.group_store,
|
|
credential_store=self.credential_store,
|
|
)
|
|
if dialog.exec():
|
|
self.session_store.update(dialog.result_session())
|
|
self.reload()
|
|
|
|
def duplicate_session(self, session: Session) -> None:
|
|
new_session = copy.deepcopy(session)
|
|
new_session.id = str(uuid.uuid4())
|
|
new_session.name = f"{session.name} (copie)"
|
|
self.session_store.add(new_session)
|
|
self.reload()
|
|
|
|
def delete_session(self, session: Session) -> None:
|
|
if QMessageBox.question(
|
|
self._parent_widget, "Supprimer", f"Supprimer la session '{session.name}' ?"
|
|
) == QMessageBox.Yes:
|
|
self.session_store.delete(session.id)
|
|
self.credential_store.delete_secret(session_credential_key(session.id))
|
|
self.reload()
|
|
|
|
# ---------- OpenSSH config ----------
|
|
def import_openssh_host(self, host: OpenSSHHost) -> None:
|
|
"""Ouvre l'editeur de session pre-rempli depuis ce Host, pour permettre
|
|
d'ajuster avant d'enregistrer (comme une connexion rapide)."""
|
|
dialog = SessionDialog(
|
|
self.key_manager, session=session_from_openssh_host(host), parent=self._parent_widget,
|
|
title=f"Importer « {host.alias} » comme session", session_store=self.session_store,
|
|
group_store=self.group_store, credential_store=self.credential_store,
|
|
)
|
|
if dialog.exec():
|
|
self.session_store.add(dialog.result_session())
|
|
self.reload()
|
|
|
|
# ---------- parametres de groupe ----------
|
|
def open_group_settings(self, group_path: str) -> None:
|
|
if self.group_store is None:
|
|
return
|
|
dialog = GroupSettingsDialog(
|
|
group_path, self.group_store, self.key_manager, self.session_store, parent=self._parent_widget,
|
|
)
|
|
dialog.exec()
|