Tags : une session peut porter plusieurs tags libres (Session.tags), saisis comme une liste separee par des virgules dans l'editeur (normalises : espaces retires, doublons insensibles a la casse elimines), avec autocompletion sur les tags deja utilises. Affiches dans l'arbre des sessions, et filtrables via un champ dedie au-dessus de l'arbre (masque les sessions ne correspondant pas, sans toucher aux dossiers/terminaux locaux/hosts OpenSSH). Parametres de groupe : choix architectural deliberement plus simple qu'une resolution dynamique a la connexion. app/groups.py (GroupSettingsStore, meme pattern que les autres stores) associe un dossier (Session.group) a des valeurs par defaut (utilisateur, port, authentification, cle, bastion, keepalive). Ces valeurs ne s'appliquent qu'au moment de la creation d'une session, via un bouton explicite "↓ Heriter du groupe" dans SessionDialog - jamais de resolution automatique/retroactive. Ce choix evite d'introduire des valeurs sentinelles "herite vs. explicite" sur chaque champ (port=22 est a la fois une valeur par defaut ET une vraie valeur possible, par exemple) dans tout le reste du code (ConnectionController, SSHConnection...), pour une fonctionnalite que la priorisation du projet place parmi les secondaires. Une session reste ensuite un ensemble de valeurs normales, librement modifiables - "surcharger une valeur heritee" est donc trivialement satisfait. Clic droit sur un dossier dans le panneau lateral : "Nouvelle session dans ce dossier..." (pre-remplit le champ Dossier) et "Parametres du groupe..." (GroupSettingsDialog). tests/test_tags_and_groups.py : persistance des tags, CRUD GroupSettingsStore, normalisation de la saisie des tags, heritage applique/absent, filtrage dans l'arbre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
374 lines
15 KiB
Python
374 lines
15 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 Qt, Signal, QObject
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QTreeWidget, QTreeWidgetItem,
|
|
QMenu, QMessageBox, QComboBox, QLabel,
|
|
)
|
|
|
|
from app.session_manager import SessionStore, Session
|
|
from app.key_manager import KeyManager
|
|
from app.groups import GroupSettingsStore
|
|
from app.local_terminal import SHELL_LABELS
|
|
from app.dialogs.session_dialog import SessionDialog
|
|
from app.dialogs.group_settings_dialog import GroupSettingsDialog
|
|
from app.openssh.config import (
|
|
OpenSSHHost, default_config_path, load_openssh_hosts, session_from_openssh_host,
|
|
)
|
|
|
|
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,
|
|
):
|
|
super().__init__(parent_widget)
|
|
self.session_store = session_store
|
|
self.key_manager = key_manager
|
|
self.group_store = group_store
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
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.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,
|
|
)
|
|
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()
|