Files
pyssh-client/app/controllers/session_controller.py
Florian a3c72ade0b feat: ajouter les Workspaces (enregistrer/restaurer l'etat des onglets)
Un workspace capture les onglets ouverts (sessions SSH, terminaux locaux,
splits avec leurs tailles) et les restaure plus tard. Ne reference les
sessions que par id : aucun mot de passe stocke, redemande a la restauration
exactement comme une ouverture normale.

- app/workspace.py : Workspace/WorkspaceTab/WorkspaceItem + WorkspaceStore
  (meme pattern que SessionStore/KeyManager : JSON atomique, schema_version).
- app/controllers/workspace_manager.py : capture() lit l'etat reel des onglets
  de ConnectionController (types de widgets, layout et tailles d'un
  SplitContainer) ; restore() rouvre chaque onglet via
  ConnectionController.build_session_tab()/build_sftp_tab()/new_local_terminal()
  (factorises hors de open_session()/open_sftp_session() pour etre reutilisables
  ici) puis reconstruit les splits avec split_tab(). Un element non restaurable
  (session supprimee depuis) est signale (compteur "ignores") plutot que de
  faire echouer toute la restauration.
- WorkspaceDialog : creer / enregistrer (ecraser) / ouvrir / renommer /
  supprimer, plus la case "Restaurer automatiquement le dernier workspace
  ouvert au demarrage" (desactivee par defaut).
- app/app_settings.py : preferences globales (theme, taille de police,
  dernier workspace) consolidees dans un seul module au lieu d'un mecanisme
  de lecture/ecriture duplique par prefernce dans theme.py.
- SplitContainer expose layout_kind et get_layout_sizes()/set_layout_sizes()
  (parcours recursif des QSplitter imbriques) pour la capture/restauration
  fidele d'une grille 2x2.

tests/test_workspace.py : capture (simple + split), sauvegarde/rechargement
JSON, restauration complete et partielle, renommer/supprimer/re-enregistrer,
restauration automatique - avec app_settings.SETTINGS_FILE redirige vers un
fichier temporaire pour ne jamais toucher les vrais reglages de l'utilisateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:47:07 +02:00

301 lines
12 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,
)
from app.session_manager import SessionStore, Session
from app.key_manager import KeyManager
from app.local_terminal import SHELL_LABELS
from app.dialogs.session_dialog import SessionDialog
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
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,
):
super().__init__(parent_widget)
self.session_store = session_store
self.key_manager = key_manager
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(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)
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)
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)
item = QTreeWidgetItem([f"🖥 {session.name} ({session.host})"])
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()
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 _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
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()
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) -> None:
dialog = SessionDialog(self.key_manager, parent=self._parent_widget, session_store=self.session_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,
)
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,
)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()