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

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