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>
This commit is contained in:
Florian
2026-09-05 02:47:07 +02:00
parent 5917e1ca04
commit a3c72ade0b
13 changed files with 730 additions and 68 deletions

View File

@@ -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