From a3c72ade0ba0e5524e68848fdc7860a59bc1b586 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 02:47:07 +0200 Subject: [PATCH] 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 --- .gitignore | 7 +- app/app_settings.py | 32 +++++ app/bastion.py | 2 +- app/controllers/connection_controller.py | 67 +++++---- app/controllers/session_controller.py | 2 +- app/controllers/workspace_manager.py | 174 +++++++++++++++++++++++ app/dialogs/workspace_dialog.py | 134 +++++++++++++++++ app/main_window.py | 23 ++- app/session_manager.py | 3 + app/theme.py | 41 ++---- app/ui/split_pane.py | 32 +++++ app/workspace.py | 123 ++++++++++++++++ tests/test_workspace.py | 158 ++++++++++++++++++++ 13 files changed, 730 insertions(+), 68 deletions(-) create mode 100644 app/app_settings.py create mode 100644 app/controllers/workspace_manager.py create mode 100644 app/dialogs/workspace_dialog.py create mode 100644 app/workspace.py create mode 100644 tests/test_workspace.py diff --git a/.gitignore b/.gitignore index d5cceed..9b7eb69 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ data/settings.json # artefacts de construction PyInstaller build/ dist/ -data/sessions.json -data/keys.json -data/known_hosts +data/sessions.json* +data/keys.json* +data/workspaces.json* +data/known_hosts* data/keys/* !data/keys/.gitkeep diff --git a/app/app_settings.py b/app/app_settings.py new file mode 100644 index 0000000..28ee5db --- /dev/null +++ b/app/app_settings.py @@ -0,0 +1,32 @@ +"""Preferences globales simples (une cle -> une valeur) dans data/settings.json : +theme, taille de police, dernier workspace ouvert... Un seul fichier partage +entre plusieurs modules plutot qu'un mecanisme de lecture/ecriture par module. +""" +from __future__ import annotations + +import json + +from app.paths import DATA_DIR +from app.storage.atomic_file import atomic_write_text + +SETTINGS_FILE = DATA_DIR / "settings.json" + + +def _load_all() -> dict: + try: + return json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def get_setting(key: str, default=None): + return _load_all().get(key, default) + + +def set_setting(key: str, value) -> None: + data = _load_all() + data[key] = value + try: + atomic_write_text(SETTINGS_FILE, json.dumps(data, indent=2)) + except OSError: + pass diff --git a/app/bastion.py b/app/bastion.py index dab41ef..d0e560e 100644 --- a/app/bastion.py +++ b/app/bastion.py @@ -52,7 +52,7 @@ def resolve_bastion_target( if bastion.mode == BASTION_SESSION: if session_store is None: raise BastionResolutionError("Bastion vers une session PySSH : aucun registre de sessions disponible.") - target = next((s for s in session_store.sessions if s.id == bastion.session_id), None) + target = session_store.get(bastion.session_id) if target is None: raise BastionResolutionError( "La session utilisee comme bastion est introuvable (supprimee ou renommee)." diff --git a/app/controllers/connection_controller.py b/app/controllers/connection_controller.py index 5e0032c..f420652 100644 --- a/app/controllers/connection_controller.py +++ b/app/controllers/connection_controller.py @@ -67,39 +67,48 @@ class ConnectionController(QObject): return False, None, "" return True, target, text - def open_session(self, session: Session) -> None: + def _ask_session_password(self, session: Session) -> tuple[bool, str]: + if session.auth_type != AUTH_PASSWORD: + return True, "" + text, ok = ask_secret( + self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :" + ) + return ok, (text if ok else "") + + def build_session_tab(self, session: Session) -> SessionTabWidget | None: + """Construit l'onglet sans l'inserer dans le QTabWidget (reutilise par + l'ouverture normale et par la restauration d'un workspace dans un panneau). + None si l'utilisateur annule (mot de passe, bastion invalide).""" ok, bastion_target, bastion_password = self._resolve_bastion(session) if not ok: - return - secret = "" - if session.auth_type == AUTH_PASSWORD: - text, ok = ask_secret( - self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :" - ) - if not ok: - return - secret = text + return None + ok, secret = self._ask_session_password(session) + if not ok: + return None + return SessionTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password) - tab = SessionTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password) + def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None: + ok, bastion_target, bastion_password = self._resolve_bastion(session) + if not ok: + return None + ok, secret = self._ask_session_password(session) + if not ok: + return None + return SFTPTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password) + + def open_session(self, session: Session) -> None: + tab = self.build_session_tab(session) + if tab is None: + return self.broadcast.register(tab) index = self.tabs.addTab(tab, f"🖥 {session.name}") self.tabs.setCurrentIndex(index) def open_sftp_session(self, session: Session) -> None: """Ouvre un onglet SFTP autonome (double panneau) pour cette session.""" - ok, bastion_target, bastion_password = self._resolve_bastion(session) - if not ok: + tab = self.build_sftp_tab(session) + if tab is None: return - secret = "" - if session.auth_type == AUTH_PASSWORD: - text, ok = ask_secret( - self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :" - ) - if not ok: - return - secret = text - - tab = SFTPTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password) index = self.tabs.addTab(tab, f"📁 {session.name}") self.tabs.setCurrentIndex(index) @@ -109,12 +118,12 @@ class ConnectionController(QObject): self.open_session(session_from_openssh_host(host)) def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None: - tab = self._new_local_terminal(shell) + tab = self.new_local_terminal(shell) label = SHELL_LABELS.get(shell, shell) index = self.tabs.addTab(tab, f"💻 {label}") self.tabs.setCurrentIndex(index) - def _new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget: + def new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget: """Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter au QTabWidget : reutilise pour un onglet normal comme pour un panneau.""" tab = LocalTerminalTabWidget(shell) @@ -253,7 +262,7 @@ class ConnectionController(QObject): # alors tout seul l'onglet correspondant (un widget ne peut avoir qu'un # parent), en decalant les onglets suivants - inutile donc d'appeler # removeTab(index) explicitement, ce qui viserait desormais un autre onglet. - container = SplitContainer(widget, layout_kind, self._new_local_terminal) + container = SplitContainer(widget, layout_kind, self.new_local_terminal) container.all_panes_closed.connect( lambda remaining, c=container: self._on_split_collapsed(c, remaining) ) @@ -269,12 +278,12 @@ class ConnectionController(QObject): self.tabs.removeTab(index) if remaining is None: return - title = self._default_title_for(remaining) + title = self.default_title_for(remaining) self.tabs.insertTab(index, remaining, title) self.tabs.setCurrentIndex(index) @staticmethod - def _default_title_for(widget: QWidget) -> str: + def default_title_for(widget: QWidget) -> str: if isinstance(widget, SessionTabWidget): return f"🖥 {widget.session.name}" if isinstance(widget, SFTPTabWidget): @@ -313,7 +322,7 @@ class ConnectionController(QObject): action = menu.exec(pane.menu_btn.mapToGlobal(pane.menu_btn.rect().bottomLeft())) if action in shell_actions: - container.replace_pane_content(pane, self._new_local_terminal(shell_actions[action])) + container.replace_pane_content(pane, self.new_local_terminal(shell_actions[action])) elif action in move_actions: index, moved_widget = move_actions[action] self.move_tab_into_pane(index, container, pane) diff --git a/app/controllers/session_controller.py b/app/controllers/session_controller.py index 07095ce..b180a82 100644 --- a/app/controllers/session_controller.py +++ b/app/controllers/session_controller.py @@ -185,7 +185,7 @@ class SessionController(QObject): session_id = item.data(0, SESSION_ROLE) if not session_id: return None - return next((s for s in self.session_store.sessions if s.id == session_id), None) + return self.session_store.get(session_id) def _shell_from_item(self, item: QTreeWidgetItem) -> str | None: if not item: diff --git a/app/controllers/workspace_manager.py b/app/controllers/workspace_manager.py new file mode 100644 index 0000000..6aed671 --- /dev/null +++ b/app/controllers/workspace_manager.py @@ -0,0 +1,174 @@ +"""Capture et restauration de l'etat de l'espace de travail (onglets, splits, +tailles) : le pont entre ConnectionController (etat d'ecran reel) et +WorkspaceStore (persistance).""" +from __future__ import annotations + +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QWidget + +from app.app_settings import get_setting, set_setting +from app.local_terminal import DEFAULT_SHELL +from app.session_manager import SessionStore +from app.workspace import ( + Workspace, WorkspaceStore, WorkspaceTab, WorkspaceItem, + ITEM_SESSION_SSH, ITEM_SESSION_SFTP, ITEM_LOCAL_SHELL, +) +from app.ui.tabs import SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget +from app.ui.split_pane import SplitContainer, LAYOUT_SINGLE +from app.controllers.connection_controller import ConnectionController + +SETTING_RESTORE_ON_STARTUP = "restore_last_workspace" +SETTING_LAST_WORKSPACE_ID = "last_workspace_id" + + +class WorkspaceManager(QObject): + workspaces_changed = Signal() + + def __init__( + self, + store: WorkspaceStore, + session_store: SessionStore, + connection_controller: ConnectionController, + parent_widget: QWidget | None = None, + ): + super().__init__(parent_widget) + self.store = store + self.session_store = session_store + self.cc = connection_controller + self._parent_widget = parent_widget + + # ---------- capture (Enregistrer) ---------- + def _item_for_widget(self, widget) -> WorkspaceItem | None: + """None = contenu non restaurable (ex: onglet SFTP dans un panneau, + type inconnu) : l'appelant doit alors sauter cet onglet/panneau.""" + if isinstance(widget, SessionTabWidget): + return WorkspaceItem(kind=ITEM_SESSION_SSH, session_id=widget.session.id) + if isinstance(widget, SFTPTabWidget): + return WorkspaceItem(kind=ITEM_SESSION_SFTP, session_id=widget.session.id) + if isinstance(widget, LocalTerminalTabWidget): + return WorkspaceItem(kind=ITEM_LOCAL_SHELL, shell=widget.shell) + return None + + def capture(self, name: str = "") -> Workspace: + """Construit un Workspace a partir de l'etat actuel des onglets. Un + onglet dont un panneau n'est pas restaurable (aucun cas actuellement, + prevu pour un futur type de panneau) est simplement omis plutot que de + faire echouer toute la capture.""" + tabs = self.cc.tabs + workspace_tabs: list[WorkspaceTab] = [] + for i in range(tabs.count()): + widget = tabs.widget(i) + title = tabs.tabText(i) + if isinstance(widget, SplitContainer): + items = [self._item_for_widget(p.content) for p in widget.panes] + if not items or any(it is None for it in items): + continue + workspace_tabs.append(WorkspaceTab( + title=title, layout=widget.layout_kind, items=items, + splitter_sizes=widget.get_layout_sizes(), + )) + else: + item = self._item_for_widget(widget) + if item is None: + continue + workspace_tabs.append(WorkspaceTab(title=title, layout=LAYOUT_SINGLE, items=[item])) + return Workspace(name=name, tabs=workspace_tabs) + + def save_as_new(self, name: str) -> Workspace: + workspace = self.capture(name) + self.store.add(workspace) + self.workspaces_changed.emit() + return workspace + + def save_over(self, workspace_id: str) -> Workspace | None: + """Ecrase un workspace existant avec l'etat actuel (garde son nom).""" + existing = self.store.get(workspace_id) + if existing is None: + return None + workspace = self.capture(existing.name) + workspace.id = existing.id + self.store.update(workspace) + self.workspaces_changed.emit() + return workspace + + def rename(self, workspace_id: str, new_name: str) -> None: + workspace = self.store.get(workspace_id) + if workspace is None: + return + workspace.name = new_name + self.store.update(workspace) + self.workspaces_changed.emit() + + def delete(self, workspace_id: str) -> None: + self.store.delete(workspace_id) + if get_setting(SETTING_LAST_WORKSPACE_ID) == workspace_id: + set_setting(SETTING_LAST_WORKSPACE_ID, "") + self.workspaces_changed.emit() + + # ---------- restauration (Ouvrir) ---------- + def _widget_for_item(self, item: WorkspaceItem): + """None = reference introuvable (session supprimee depuis) : l'appelant + doit sauter cet element plutot qu'echouer toute la restauration.""" + if item.kind == ITEM_LOCAL_SHELL: + return self.cc.new_local_terminal(item.shell or DEFAULT_SHELL) + session = self.session_store.get(item.session_id) + if session is None: + return None + if item.kind == ITEM_SESSION_SSH: + widget = self.cc.build_session_tab(session) + elif item.kind == ITEM_SESSION_SFTP: + widget = self.cc.build_sftp_tab(session) + else: + return None + if widget is not None: + self.cc.broadcast.register(widget) + return widget + + def restore(self, workspace: Workspace) -> int: + """Ouvre chaque onglet du workspace. Renvoie le nombre d'onglets/ + panneaux ignores (reference introuvable), pour que l'appelant puisse + en informer l'utilisateur si besoin.""" + skipped = 0 + for wtab in workspace.tabs: + skipped += self._restore_tab(wtab) + set_setting(SETTING_LAST_WORKSPACE_ID, workspace.id) + return skipped + + def _restore_tab(self, wtab: WorkspaceTab) -> int: + if not wtab.items: + return 0 + if wtab.layout == LAYOUT_SINGLE or len(wtab.items) == 1: + widget = self._widget_for_item(wtab.items[0]) + if widget is None: + return 1 + index = self.cc.tabs.addTab(widget, wtab.title or self.cc.default_title_for(widget)) + self.cc.tabs.setCurrentIndex(index) + return 0 + + first = self._widget_for_item(wtab.items[0]) + if first is None: + return len(wtab.items) + index = self.cc.tabs.addTab(first, wtab.title or "⊞ Split") + self.cc.split_tab(index, wtab.layout) + container = self.cc.tabs.widget(index) + skipped = 0 + for pane, item in zip(container.panes[1:], wtab.items[1:]): + widget = self._widget_for_item(item) + if widget is None: + skipped += 1 + continue + container.replace_pane_content(pane, widget) + if wtab.splitter_sizes: + container.set_layout_sizes(wtab.splitter_sizes) + return skipped + + # ---------- restauration automatique au demarrage ---------- + def try_restore_last_on_startup(self) -> bool: + if not get_setting(SETTING_RESTORE_ON_STARTUP, False): + return False + workspace_id = get_setting(SETTING_LAST_WORKSPACE_ID, "") + workspace = self.store.get(workspace_id) if workspace_id else None + if workspace is None or not workspace.tabs: + return False + self.restore(workspace) + return True diff --git a/app/dialogs/workspace_dialog.py b/app/dialogs/workspace_dialog.py new file mode 100644 index 0000000..1cfd45b --- /dev/null +++ b/app/dialogs/workspace_dialog.py @@ -0,0 +1,134 @@ +"""Gestionnaire de workspaces : creer, enregistrer, ouvrir, renommer, supprimer.""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, + QMessageBox, QInputDialog, QLabel, QCheckBox, +) + +from app.app_settings import get_setting, set_setting +from app.controllers.workspace_manager import WorkspaceManager, SETTING_RESTORE_ON_STARTUP + + +class WorkspaceDialog(QDialog): + def __init__(self, manager: WorkspaceManager, parent=None): + super().__init__(parent) + self.manager = manager + self.setWindowTitle("Workspaces") + self.setMinimumSize(480, 360) + + layout = QVBoxLayout(self) + info = QLabel( + "Un workspace enregistre les onglets ouverts (sessions, terminaux locaux, " + "splits) pour les retrouver plus tard. Aucun mot de passe n'y est stocke : " + "il sera redemande a l'ouverture si necessaire." + ) + info.setWordWrap(True) + layout.addWidget(info) + + self.list = QListWidget() + layout.addWidget(self.list) + + btn_row = QHBoxLayout() + self.new_btn = QPushButton("Nouveau (etat actuel)...") + self.save_btn = QPushButton("Enregistrer (ecraser)") + self.open_btn = QPushButton("Ouvrir") + self.rename_btn = QPushButton("Renommer...") + self.delete_btn = QPushButton("Supprimer") + for b in (self.new_btn, self.save_btn, self.open_btn, self.rename_btn, self.delete_btn): + btn_row.addWidget(b) + layout.addLayout(btn_row) + + self.restore_check = QCheckBox("Restaurer automatiquement le dernier workspace ouvert au demarrage") + self.restore_check.setChecked(bool(get_setting(SETTING_RESTORE_ON_STARTUP, False))) + self.restore_check.toggled.connect(lambda checked: set_setting(SETTING_RESTORE_ON_STARTUP, checked)) + layout.addWidget(self.restore_check) + + close_row = QHBoxLayout() + close_row.addStretch() + close_btn = QPushButton("Fermer") + close_btn.clicked.connect(self.accept) + close_row.addWidget(close_btn) + layout.addLayout(close_row) + + self.new_btn.clicked.connect(self.create_new) + self.save_btn.clicked.connect(self.save_over_selected) + self.open_btn.clicked.connect(self.open_selected) + self.rename_btn.clicked.connect(self.rename_selected) + self.delete_btn.clicked.connect(self.delete_selected) + self.list.itemDoubleClicked.connect(lambda _item: self.open_selected()) + self.list.currentItemChanged.connect(self._update_buttons) + manager.workspaces_changed.connect(self.refresh) + + self.refresh() + + def refresh(self) -> None: + self.list.clear() + for workspace in self.manager.store.workspaces: + item = QListWidgetItem(f"{workspace.name or '(sans nom)'} — {len(workspace.tabs)} onglet(s)") + item.setData(Qt.UserRole, workspace.id) + self.list.addItem(item) + self._update_buttons() + + def _current_id(self) -> str | None: + item = self.list.currentItem() + return item.data(Qt.UserRole) if item else None + + def _update_buttons(self) -> None: + has_selection = self._current_id() is not None + for b in (self.save_btn, self.open_btn, self.rename_btn, self.delete_btn): + b.setEnabled(has_selection) + + def create_new(self) -> None: + name, ok = QInputDialog.getText(self, "Nouveau workspace", "Nom du workspace :") + if not ok or not name.strip(): + return + self.manager.save_as_new(name.strip()) + + def save_over_selected(self) -> None: + workspace_id = self._current_id() + if not workspace_id: + return + workspace = self.manager.store.get(workspace_id) + name = workspace.name if workspace else "" + if QMessageBox.question( + self, "Enregistrer", + f"Remplacer le contenu du workspace « {name} » par les onglets actuellement ouverts ?", + ) != QMessageBox.Yes: + return + self.manager.save_over(workspace_id) + + def open_selected(self) -> None: + workspace_id = self._current_id() + if not workspace_id: + return + workspace = self.manager.store.get(workspace_id) + if not workspace: + return + skipped = self.manager.restore(workspace) + if skipped: + QMessageBox.information( + self, "Restauration partielle", + f"{skipped} element(s) n'ont pas pu etre restaures (session supprimee depuis).", + ) + + def rename_selected(self) -> None: + workspace_id = self._current_id() + if not workspace_id: + return + workspace = self.manager.store.get(workspace_id) + name, ok = QInputDialog.getText( + self, "Renommer", "Nouveau nom :", text=workspace.name if workspace else "", + ) + if ok and name.strip(): + self.manager.rename(workspace_id, name.strip()) + + def delete_selected(self) -> None: + workspace_id = self._current_id() + if not workspace_id: + return + workspace = self.manager.store.get(workspace_id) + name = workspace.name if workspace else "" + if QMessageBox.question(self, "Supprimer", f"Supprimer le workspace « {name} » ?") == QMessageBox.Yes: + self.manager.delete(workspace_id) diff --git a/app/main_window.py b/app/main_window.py index 78e685e..759ec42 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -18,10 +18,13 @@ from app.local_terminal import SHELL_LABELS, DEFAULT_SHELL from app.terminal_widget import TerminalWidget from app.dialogs.key_dialog import KeyManagerDialog from app.dialogs.known_hosts_dialog import KnownHostsDialog +from app.dialogs.workspace_dialog import WorkspaceDialog from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme +from app.workspace import WorkspaceStore from app.controllers.broadcast_manager import BroadcastManager from app.controllers.session_controller import SessionController from app.controllers.connection_controller import ConnectionController +from app.controllers.workspace_manager import WorkspaceManager from app.ui.split_pane import SplitContainer # re-exporte pour compatibilite (utilise par tests/test_detect.py et du code existant) @@ -36,6 +39,7 @@ class MainWindow(QMainWindow): self.session_store = SessionStore() self.key_manager = KeyManager() + self.workspace_store = WorkspaceStore() self.broadcast = BroadcastManager(self._iter_tabs, self) self.connection_controller = ConnectionController( @@ -44,6 +48,9 @@ class MainWindow(QMainWindow): self.session_controller = SessionController( self.session_store, self.key_manager, self.broadcast.button, parent_widget=self, ) + self.workspace_manager = WorkspaceManager( + self.workspace_store, self.session_store, self.connection_controller, parent_widget=self, + ) self._wire_controllers() self._build_menu() @@ -51,8 +58,10 @@ class MainWindow(QMainWindow): self.setCentralWidget(self.connection_controller.tabs) self.setStatusBar(QStatusBar()) - # demarre directement dans un terminal utilisable, sans avoir a en choisir un - self.connection_controller.open_local_terminal(DEFAULT_SHELL) + # demarre directement dans un terminal utilisable, sans avoir a en choisir un - + # sauf si un workspace vient d'etre restaure automatiquement (il a deja le sien) + if not self.workspace_manager.try_restore_last_on_startup(): + self.connection_controller.open_local_terminal(DEFAULT_SHELL) # ---------- acces retro-compatible (tests, code externe) ---------- @property @@ -151,6 +160,12 @@ class MainWindow(QMainWindow): manage_hosts_action.triggered.connect(self.open_known_hosts_manager) hosts_menu.addAction(manage_hosts_action) + workspaces_menu = self.menuBar().addMenu("&Workspaces") + manage_workspaces_action = QAction("Gestionnaire de workspaces...", self) + manage_workspaces_action.setShortcut(QKeySequence("Ctrl+Shift+W")) + manage_workspaces_action.triggered.connect(self.open_workspace_manager) + workspaces_menu.addAction(manage_workspaces_action) + help_menu = self.menuBar().addMenu("&Aide") about_action = QAction("A propos", self) about_action.triggered.connect(self.show_about) @@ -200,6 +215,10 @@ class MainWindow(QMainWindow): dialog = KnownHostsDialog(parent=self) dialog.exec() + def open_workspace_manager(self) -> None: + dialog = WorkspaceDialog(self.workspace_manager, parent=self) + dialog.exec() + def show_about(self) -> None: QMessageBox.information( self, "A propos", diff --git a/app/session_manager.py b/app/session_manager.py index 6253e67..ba134bb 100644 --- a/app/session_manager.py +++ b/app/session_manager.py @@ -118,6 +118,9 @@ class SessionStore: payload = {"schema_version": SCHEMA_VERSION, "sessions": [s.to_dict() for s in self.sessions]} save_json_document(self.path, payload) + def get(self, session_id: str) -> Session | None: + return next((s for s in self.sessions if s.id == session_id), None) + def add(self, session: Session) -> None: self.sessions.append(session) self.save() diff --git a/app/theme.py b/app/theme.py index 9176098..627a9e6 100644 --- a/app/theme.py +++ b/app/theme.py @@ -6,19 +6,16 @@ choisie : une seule feuille de style a maintenir au lieu de trois quasi identiqu """ from __future__ import annotations -import json -from pathlib import Path from string import Template from PySide6.QtCore import Qt from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QLinearGradient from PySide6.QtWidgets import QApplication -from app.paths import RESOURCES_DIR, DATA_DIR # noqa: E402 (chemins valables aussi en .exe) -from app.storage.atomic_file import atomic_write_text +from app.paths import RESOURCES_DIR # noqa: E402 (chemins valables aussi en .exe) +from app.app_settings import get_setting, set_setting STYLESHEET_PATH = RESOURCES_DIR / "theme.qss" -SETTINGS_FILE = DATA_DIR / "settings.json" THEME_LIGHT = "light" THEME_MIDNIGHT = "midnight" @@ -107,18 +104,12 @@ _current_theme = DEFAULT_THEME # Preference persistante # --------------------------------------------------------------------------- def load_saved_theme() -> str: - try: - data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) - theme = data.get("theme") - if theme in UI_PALETTES: - return theme - except (OSError, json.JSONDecodeError): - pass - return DEFAULT_THEME + theme = get_setting("theme") + return theme if theme in UI_PALETTES else DEFAULT_THEME def save_theme(theme: str) -> None: - _update_settings("theme", theme) + set_setting("theme", theme) DEFAULT_FONT_SIZE = 10 @@ -126,28 +117,14 @@ DEFAULT_FONT_SIZE = 10 def load_font_size() -> int: try: - data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) - size = int(data.get("font_size", DEFAULT_FONT_SIZE)) - return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE - except (OSError, json.JSONDecodeError, TypeError, ValueError): + size = int(get_setting("font_size", DEFAULT_FONT_SIZE)) + except (TypeError, ValueError): return DEFAULT_FONT_SIZE + return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE def save_font_size(size: int) -> None: - _update_settings("font_size", size) - - -def _update_settings(key: str, value) -> None: - data = {} - try: - data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - pass - data[key] = value - try: - atomic_write_text(SETTINGS_FILE, json.dumps(data, indent=2)) - except OSError: - pass + set_setting("font_size", size) def current_theme() -> str: diff --git a/app/ui/split_pane.py b/app/ui/split_pane.py index 0c32e11..390db79 100644 --- a/app/ui/split_pane.py +++ b/app/ui/split_pane.py @@ -82,6 +82,7 @@ class SplitContainer(QWidget): def __init__(self, first_widget: QWidget, layout_kind: str, make_filler: Callable[[], QWidget], parent=None): super().__init__(parent) + self.layout_kind = layout_kind self.panes: list[PaneSlot] = [] self._root_layout = QVBoxLayout(self) self._root_layout.setContentsMargins(0, 0, 0, 0) @@ -148,3 +149,34 @@ class SplitContainer(QWidget): def close_all(self) -> None: for pane in self.panes: pane.close_connection() + + # ---------- tailles (capture/restauration d'un workspace) ---------- + def get_layout_sizes(self) -> list[list[int]]: + """Tailles (pixels) de chaque QSplitter de la grille, dans un ordre + stable (parcours en profondeur depuis la racine).""" + sizes: list[list[int]] = [] + + def collect(widget: QWidget) -> None: + if isinstance(widget, QSplitter): + sizes.append(widget.sizes()) + for i in range(widget.count()): + collect(widget.widget(i)) + + root_item = self._root_layout.itemAt(0) + if root_item and root_item.widget(): + collect(root_item.widget()) + return sizes + + def set_layout_sizes(self, sizes: list[list[int]]) -> None: + remaining = list(sizes) + + def apply(widget: QWidget) -> None: + if isinstance(widget, QSplitter): + if remaining: + widget.setSizes(remaining.pop(0)) + for i in range(widget.count()): + apply(widget.widget(i)) + + root_item = self._root_layout.itemAt(0) + if root_item and root_item.widget(): + apply(root_item.widget()) diff --git a/app/workspace.py b/app/workspace.py new file mode 100644 index 0000000..91eca07 --- /dev/null +++ b/app/workspace.py @@ -0,0 +1,123 @@ +"""Workspaces : composition de l'espace de travail (onglets, splits, tailles), +enregistrable et restaurable. + +Un workspace ne reference les sessions SSH que par id : jamais de mot de passe +ni de secret stocke ici (la connexion, une fois restauree, redemande un +mot de passe/passphrase exactement comme une ouverture normale).""" +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field, asdict +from pathlib import Path + +from app.paths import DATA_DIR +from app.storage.json_store import load_json_document, save_json_document + +WORKSPACES_FILE = DATA_DIR / "workspaces.json" +SCHEMA_VERSION = 1 + +ITEM_SESSION_SSH = "session_ssh" # onglet terminal pour une session enregistree +ITEM_SESSION_SFTP = "session_sftp" # onglet SFTP pour une session enregistree +ITEM_LOCAL_SHELL = "local_shell" # terminal local + + +@dataclass +class WorkspaceItem: + """Contenu d'un onglet ou d'un panneau.""" + kind: str = ITEM_LOCAL_SHELL + session_id: str = "" # kind in (session_ssh, session_sftp) + shell: str = "" # kind == local_shell + + def to_dict(self) -> dict: + return asdict(self) + + @staticmethod + def from_dict(data: dict) -> "WorkspaceItem": + item = WorkspaceItem() + for k in WorkspaceItem.__dataclass_fields__: + if k in data: + setattr(item, k, data[k]) + return item + + +@dataclass +class WorkspaceTab: + title: str = "" + layout: str = "1" # cf. app.ui.split_pane : "1" | "2v" | "2h" | "4" + items: list[WorkspaceItem] = field(default_factory=list) + splitter_sizes: list[list[int]] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "title": self.title, + "layout": self.layout, + "items": [i.to_dict() for i in self.items], + "splitter_sizes": [list(s) for s in self.splitter_sizes], + } + + @staticmethod + def from_dict(data: dict) -> "WorkspaceTab": + tab = WorkspaceTab(title=data.get("title", ""), layout=data.get("layout", "1")) + tab.items = [WorkspaceItem.from_dict(i) for i in data.get("items", [])] + tab.splitter_sizes = [list(s) for s in data.get("splitter_sizes", [])] + return tab + + +@dataclass +class Workspace: + id: str = field(default_factory=lambda: str(uuid.uuid4())) + name: str = "" + tabs: list[WorkspaceTab] = field(default_factory=list) + + def to_dict(self) -> dict: + return {"id": self.id, "name": self.name, "tabs": [t.to_dict() for t in self.tabs]} + + @staticmethod + def from_dict(data: dict) -> "Workspace": + ws = Workspace(id=data.get("id") or str(uuid.uuid4()), name=data.get("name", "")) + ws.tabs = [WorkspaceTab.from_dict(t) for t in data.get("tabs", [])] + return ws + + +class WorkspaceStore: + """Charge/sauvegarde la liste des workspaces depuis data/workspaces.json.""" + + def __init__(self, path: Path = WORKSPACES_FILE): + self.path = path + self.workspaces: list[Workspace] = [] + self.load_warning: str | None = None + self.load() + + def load(self) -> None: + was_missing = not self.path.exists() + document, warning = load_json_document( + self.path, + default={"schema_version": SCHEMA_VERSION, "workspaces": []}, + current_version=SCHEMA_VERSION, + ) + self.load_warning = warning + self.workspaces = [Workspace.from_dict(w) for w in document.get("workspaces", [])] + if was_missing: + self.save() + + def save(self) -> None: + payload = {"schema_version": SCHEMA_VERSION, "workspaces": [w.to_dict() for w in self.workspaces]} + save_json_document(self.path, payload) + + def get(self, workspace_id: str) -> Workspace | None: + return next((w for w in self.workspaces if w.id == workspace_id), None) + + def add(self, workspace: Workspace) -> None: + self.workspaces.append(workspace) + self.save() + + def update(self, workspace: Workspace) -> None: + for i, w in enumerate(self.workspaces): + if w.id == workspace.id: + self.workspaces[i] = workspace + break + self.save() + + def delete(self, workspace_id: str) -> None: + self.workspaces = [w for w in self.workspaces if w.id != workspace_id] + self.save() diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 0000000..9ec087d --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,158 @@ +"""Workspaces : capture de l'etat des onglets (avec splits), sauvegarde/ +rechargement JSON, restauration (y compris partielle si une session referencee +a ete supprimee depuis), et parametre de restauration automatique au demarrage. + +SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees (pas de +connexion reelle necessaire). app.app_settings.SETTINGS_FILE est redirige vers +un fichier temporaire pour ne jamais toucher les vrais reglages de l'utilisateur +(restore() met a jour last_workspace_id a chaque restauration).""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import itertools +import tempfile +from pathlib import Path +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication, QWidget + +from app.session_manager import Session, SessionStore +from app.key_manager import KeyManager +from app.ssh_connection import SSHConnection +from app.local_terminal import LocalTerminalConnection +from app.controllers.broadcast_manager import BroadcastManager +from app.controllers.connection_controller import ConnectionController +from app.controllers.workspace_manager import ( + WorkspaceManager, SETTING_RESTORE_ON_STARTUP, SETTING_LAST_WORKSPACE_ID, +) +from app.workspace import WorkspaceStore, ITEM_SESSION_SSH, ITEM_LOCAL_SHELL +from app.ui.split_pane import LAYOUT_2V +import app.app_settings as app_settings + +app = QApplication.instance() or QApplication([]) + +tmp = Path(tempfile.mkdtemp()) +_counter = itertools.count() + +no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None) +no_local_thread = patch.object(LocalTerminalConnection, "start", lambda self: None) +settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json") +no_ssh_thread.start() +no_local_thread.start() +settings_redirect.start() + + +def make_setup(): + n = next(_counter) + session_store = SessionStore(path=tmp / f"sessions_{n}.json") + key_manager = KeyManager(registry_path=tmp / f"keys_{n}.json") + workspace_store = WorkspaceStore(path=tmp / f"workspaces_{n}.json") + host_widget = QWidget() + broadcast = BroadcastManager(lambda: [host_widget]) + cc = ConnectionController(session_store, key_manager, broadcast, parent_widget=host_widget) + wm = WorkspaceManager(workspace_store, session_store, cc, parent_widget=host_widget) + return session_store, cc, wm + + +print("=== 1. Capture : onglet simple + onglet divise ===") +session_store, cc, wm = make_setup() +saved_session = Session(name="prod-web", host="10.0.0.5", username="root") +session_store.add(saved_session) + +cc.open_local_terminal() # onglet 0 : terminal local simple +cc.open_session(saved_session) # onglet 1 : deviendra un split +cc.split_tab(1, LAYOUT_2V) + +workspace = wm.capture(name="Homelab") +print(f" {len(workspace.tabs)} onglet(s) captures") +assert len(workspace.tabs) == 2 +assert workspace.tabs[0].items[0].kind == ITEM_LOCAL_SHELL +assert workspace.tabs[1].layout == LAYOUT_2V +assert len(workspace.tabs[1].items) == 2 +assert workspace.tabs[1].items[0].kind == ITEM_SESSION_SSH +assert workspace.tabs[1].items[0].session_id == saved_session.id +assert workspace.tabs[1].items[1].kind == ITEM_LOCAL_SHELL # panneau de remplissage +print(" OK") + +print() +print("=== 2. Sauvegarde persistee (relecture depuis le disque) ===") +saved = wm.save_as_new("Homelab") +reloaded_store = WorkspaceStore(path=wm.store.path) +reloaded = reloaded_store.get(saved.id) +print(" workspace relu:", reloaded.name if reloaded else None) +assert reloaded is not None +assert reloaded.name == "Homelab" +assert len(reloaded.tabs) == 2 +assert reloaded.tabs[1].items[0].session_id == saved_session.id +print(" OK") + +print() +print("=== 3. Restauration : les onglets sont recrees a l'identique ===") +session_store2, cc2, wm2 = make_setup() +session_store2.add(saved_session) +skipped = wm2.restore(reloaded) +print(f" onglets ouverts: {cc2.tabs.count()}, elements ignores: {skipped}") +assert cc2.tabs.count() == 2 +assert skipped == 0 +from app.ui.split_pane import SplitContainer +from app.ui.tabs import SessionTabWidget, LocalTerminalTabWidget +assert isinstance(cc2.tabs.widget(0), LocalTerminalTabWidget) +container = cc2.tabs.widget(1) +assert isinstance(container, SplitContainer) +assert isinstance(container.panes[0].content, SessionTabWidget) +assert container.panes[0].content.session.id == saved_session.id +print(" OK") + +print() +print("=== 4. Restauration partielle : session supprimee depuis -> onglet ignore ===") +session_store3, cc3, wm3 = make_setup() +# session_store3 ne contient PAS saved_session : simule une session supprimee +skipped3 = wm3.restore(reloaded) +print(f" onglets ouverts: {cc3.tabs.count()}, elements ignores: {skipped3}") +assert skipped3 >= 1 +assert cc3.tabs.count() == 1 # seul le terminal local (onglet 0) a pu etre restaure +print(" OK") + +print() +print("=== 5. Renommer / Supprimer ===") +_, _, wm4 = make_setup() +ws = wm4.save_as_new("temporaire") +wm4.rename(ws.id, "renomme") +assert wm4.store.get(ws.id).name == "renomme" +wm4.delete(ws.id) +assert wm4.store.get(ws.id) is None +print(" OK") + +print() +print("=== 6. Enregistrer par-dessus (ecrase le contenu, garde le nom et l'id) ===") +session_store5, cc5, wm5 = make_setup() +cc5.open_local_terminal() +ws5 = wm5.save_as_new("evolutif") +assert len(wm5.store.get(ws5.id).tabs) == 1 +cc5.open_local_terminal() +wm5.save_over(ws5.id) +updated = wm5.store.get(ws5.id) +print(f" onglets apres re-enregistrement: {len(updated.tabs)}") +assert updated.id == ws5.id +assert updated.name == "evolutif" +assert len(updated.tabs) == 2 +print(" OK") + +print() +print("=== 7. Restauration automatique au demarrage (parametre global) ===") +_, cc6, wm6 = make_setup() +cc6.open_local_terminal() +ws6 = wm6.save_as_new("auto") +assert wm6.try_restore_last_on_startup() is False, "desactive par defaut" +app_settings.set_setting(SETTING_RESTORE_ON_STARTUP, True) +app_settings.set_setting(SETTING_LAST_WORKSPACE_ID, ws6.id) +assert wm6.try_restore_last_on_startup() is True +app_settings.set_setting(SETTING_RESTORE_ON_STARTUP, False) +print(" OK") + +no_ssh_thread.stop() +no_local_thread.stop() +settings_redirect.stop() + +print() +print("WORKSPACES : TOUS LES TESTS OK")