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

7
.gitignore vendored
View File

@@ -10,8 +10,9 @@ data/settings.json
# artefacts de construction PyInstaller # artefacts de construction PyInstaller
build/ build/
dist/ dist/
data/sessions.json data/sessions.json*
data/keys.json data/keys.json*
data/known_hosts data/workspaces.json*
data/known_hosts*
data/keys/* data/keys/*
!data/keys/.gitkeep !data/keys/.gitkeep

32
app/app_settings.py Normal file
View File

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

View File

@@ -52,7 +52,7 @@ def resolve_bastion_target(
if bastion.mode == BASTION_SESSION: if bastion.mode == BASTION_SESSION:
if session_store is None: if session_store is None:
raise BastionResolutionError("Bastion vers une session PySSH : aucun registre de sessions disponible.") 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: if target is None:
raise BastionResolutionError( raise BastionResolutionError(
"La session utilisee comme bastion est introuvable (supprimee ou renommee)." "La session utilisee comme bastion est introuvable (supprimee ou renommee)."

View File

@@ -67,39 +67,48 @@ class ConnectionController(QObject):
return False, None, "" return False, None, ""
return True, target, text return True, target, text
def open_session(self, session: Session) -> None: def _ask_session_password(self, session: Session) -> tuple[bool, str]:
ok, bastion_target, bastion_password = self._resolve_bastion(session) if session.auth_type != AUTH_PASSWORD:
if not ok: return True, ""
return
secret = ""
if session.auth_type == AUTH_PASSWORD:
text, ok = ask_secret( text, ok = ask_secret(
self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :" self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :"
) )
if not ok: return ok, (text if ok else "")
return
secret = text
tab = SessionTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password) 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 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)
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) self.broadcast.register(tab)
index = self.tabs.addTab(tab, f"🖥 {session.name}") index = self.tabs.addTab(tab, f"🖥 {session.name}")
self.tabs.setCurrentIndex(index) self.tabs.setCurrentIndex(index)
def open_sftp_session(self, session: Session) -> None: def open_sftp_session(self, session: Session) -> None:
"""Ouvre un onglet SFTP autonome (double panneau) pour cette session.""" """Ouvre un onglet SFTP autonome (double panneau) pour cette session."""
ok, bastion_target, bastion_password = self._resolve_bastion(session) tab = self.build_sftp_tab(session)
if not ok: if tab is None:
return 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}") index = self.tabs.addTab(tab, f"📁 {session.name}")
self.tabs.setCurrentIndex(index) self.tabs.setCurrentIndex(index)
@@ -109,12 +118,12 @@ class ConnectionController(QObject):
self.open_session(session_from_openssh_host(host)) self.open_session(session_from_openssh_host(host))
def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None: 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) label = SHELL_LABELS.get(shell, shell)
index = self.tabs.addTab(tab, f"💻 {label}") index = self.tabs.addTab(tab, f"💻 {label}")
self.tabs.setCurrentIndex(index) 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 """Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter
au QTabWidget : reutilise pour un onglet normal comme pour un panneau.""" au QTabWidget : reutilise pour un onglet normal comme pour un panneau."""
tab = LocalTerminalTabWidget(shell) tab = LocalTerminalTabWidget(shell)
@@ -253,7 +262,7 @@ class ConnectionController(QObject):
# alors tout seul l'onglet correspondant (un widget ne peut avoir qu'un # alors tout seul l'onglet correspondant (un widget ne peut avoir qu'un
# parent), en decalant les onglets suivants - inutile donc d'appeler # parent), en decalant les onglets suivants - inutile donc d'appeler
# removeTab(index) explicitement, ce qui viserait desormais un autre onglet. # 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( container.all_panes_closed.connect(
lambda remaining, c=container: self._on_split_collapsed(c, remaining) lambda remaining, c=container: self._on_split_collapsed(c, remaining)
) )
@@ -269,12 +278,12 @@ class ConnectionController(QObject):
self.tabs.removeTab(index) self.tabs.removeTab(index)
if remaining is None: if remaining is None:
return return
title = self._default_title_for(remaining) title = self.default_title_for(remaining)
self.tabs.insertTab(index, remaining, title) self.tabs.insertTab(index, remaining, title)
self.tabs.setCurrentIndex(index) self.tabs.setCurrentIndex(index)
@staticmethod @staticmethod
def _default_title_for(widget: QWidget) -> str: def default_title_for(widget: QWidget) -> str:
if isinstance(widget, SessionTabWidget): if isinstance(widget, SessionTabWidget):
return f"🖥 {widget.session.name}" return f"🖥 {widget.session.name}"
if isinstance(widget, SFTPTabWidget): if isinstance(widget, SFTPTabWidget):
@@ -313,7 +322,7 @@ class ConnectionController(QObject):
action = menu.exec(pane.menu_btn.mapToGlobal(pane.menu_btn.rect().bottomLeft())) action = menu.exec(pane.menu_btn.mapToGlobal(pane.menu_btn.rect().bottomLeft()))
if action in shell_actions: 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: elif action in move_actions:
index, moved_widget = move_actions[action] index, moved_widget = move_actions[action]
self.move_tab_into_pane(index, container, pane) self.move_tab_into_pane(index, container, pane)

View File

@@ -185,7 +185,7 @@ class SessionController(QObject):
session_id = item.data(0, SESSION_ROLE) session_id = item.data(0, SESSION_ROLE)
if not session_id: if not session_id:
return None 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: def _shell_from_item(self, item: QTreeWidgetItem) -> str | None:
if not item: if not item:

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

View File

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

View File

@@ -18,10 +18,13 @@ from app.local_terminal import SHELL_LABELS, DEFAULT_SHELL
from app.terminal_widget import TerminalWidget from app.terminal_widget import TerminalWidget
from app.dialogs.key_dialog import KeyManagerDialog from app.dialogs.key_dialog import KeyManagerDialog
from app.dialogs.known_hosts_dialog import KnownHostsDialog 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.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.broadcast_manager import BroadcastManager
from app.controllers.session_controller import SessionController from app.controllers.session_controller import SessionController
from app.controllers.connection_controller import ConnectionController from app.controllers.connection_controller import ConnectionController
from app.controllers.workspace_manager import WorkspaceManager
from app.ui.split_pane import SplitContainer from app.ui.split_pane import SplitContainer
# re-exporte pour compatibilite (utilise par tests/test_detect.py et du code existant) # 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.session_store = SessionStore()
self.key_manager = KeyManager() self.key_manager = KeyManager()
self.workspace_store = WorkspaceStore()
self.broadcast = BroadcastManager(self._iter_tabs, self) self.broadcast = BroadcastManager(self._iter_tabs, self)
self.connection_controller = ConnectionController( self.connection_controller = ConnectionController(
@@ -44,6 +48,9 @@ class MainWindow(QMainWindow):
self.session_controller = SessionController( self.session_controller = SessionController(
self.session_store, self.key_manager, self.broadcast.button, parent_widget=self, 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._wire_controllers()
self._build_menu() self._build_menu()
@@ -51,7 +58,9 @@ class MainWindow(QMainWindow):
self.setCentralWidget(self.connection_controller.tabs) self.setCentralWidget(self.connection_controller.tabs)
self.setStatusBar(QStatusBar()) self.setStatusBar(QStatusBar())
# demarre directement dans un terminal utilisable, sans avoir a en choisir un # 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) self.connection_controller.open_local_terminal(DEFAULT_SHELL)
# ---------- acces retro-compatible (tests, code externe) ---------- # ---------- acces retro-compatible (tests, code externe) ----------
@@ -151,6 +160,12 @@ class MainWindow(QMainWindow):
manage_hosts_action.triggered.connect(self.open_known_hosts_manager) manage_hosts_action.triggered.connect(self.open_known_hosts_manager)
hosts_menu.addAction(manage_hosts_action) 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") help_menu = self.menuBar().addMenu("&Aide")
about_action = QAction("A propos", self) about_action = QAction("A propos", self)
about_action.triggered.connect(self.show_about) about_action.triggered.connect(self.show_about)
@@ -200,6 +215,10 @@ class MainWindow(QMainWindow):
dialog = KnownHostsDialog(parent=self) dialog = KnownHostsDialog(parent=self)
dialog.exec() dialog.exec()
def open_workspace_manager(self) -> None:
dialog = WorkspaceDialog(self.workspace_manager, parent=self)
dialog.exec()
def show_about(self) -> None: def show_about(self) -> None:
QMessageBox.information( QMessageBox.information(
self, "A propos", self, "A propos",

View File

@@ -118,6 +118,9 @@ class SessionStore:
payload = {"schema_version": SCHEMA_VERSION, "sessions": [s.to_dict() for s in self.sessions]} payload = {"schema_version": SCHEMA_VERSION, "sessions": [s.to_dict() for s in self.sessions]}
save_json_document(self.path, payload) 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: def add(self, session: Session) -> None:
self.sessions.append(session) self.sessions.append(session)
self.save() self.save()

View File

@@ -6,19 +6,16 @@ choisie : une seule feuille de style a maintenir au lieu de trois quasi identiqu
""" """
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
from string import Template from string import Template
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QLinearGradient from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QLinearGradient
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from app.paths import RESOURCES_DIR, DATA_DIR # noqa: E402 (chemins valables aussi en .exe) from app.paths import RESOURCES_DIR # noqa: E402 (chemins valables aussi en .exe)
from app.storage.atomic_file import atomic_write_text from app.app_settings import get_setting, set_setting
STYLESHEET_PATH = RESOURCES_DIR / "theme.qss" STYLESHEET_PATH = RESOURCES_DIR / "theme.qss"
SETTINGS_FILE = DATA_DIR / "settings.json"
THEME_LIGHT = "light" THEME_LIGHT = "light"
THEME_MIDNIGHT = "midnight" THEME_MIDNIGHT = "midnight"
@@ -107,18 +104,12 @@ _current_theme = DEFAULT_THEME
# Preference persistante # Preference persistante
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def load_saved_theme() -> str: def load_saved_theme() -> str:
try: theme = get_setting("theme")
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) return theme if theme in UI_PALETTES else DEFAULT_THEME
theme = data.get("theme")
if theme in UI_PALETTES:
return theme
except (OSError, json.JSONDecodeError):
pass
return DEFAULT_THEME
def save_theme(theme: str) -> None: def save_theme(theme: str) -> None:
_update_settings("theme", theme) set_setting("theme", theme)
DEFAULT_FONT_SIZE = 10 DEFAULT_FONT_SIZE = 10
@@ -126,28 +117,14 @@ DEFAULT_FONT_SIZE = 10
def load_font_size() -> int: def load_font_size() -> int:
try: try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) size = int(get_setting("font_size", DEFAULT_FONT_SIZE))
size = int(data.get("font_size", DEFAULT_FONT_SIZE)) except (TypeError, ValueError):
return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return DEFAULT_FONT_SIZE return DEFAULT_FONT_SIZE
return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE
def save_font_size(size: int) -> None: def save_font_size(size: int) -> None:
_update_settings("font_size", size) set_setting("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
def current_theme() -> str: def current_theme() -> str:

View File

@@ -82,6 +82,7 @@ class SplitContainer(QWidget):
def __init__(self, first_widget: QWidget, layout_kind: str, make_filler: Callable[[], QWidget], parent=None): def __init__(self, first_widget: QWidget, layout_kind: str, make_filler: Callable[[], QWidget], parent=None):
super().__init__(parent) super().__init__(parent)
self.layout_kind = layout_kind
self.panes: list[PaneSlot] = [] self.panes: list[PaneSlot] = []
self._root_layout = QVBoxLayout(self) self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0) self._root_layout.setContentsMargins(0, 0, 0, 0)
@@ -148,3 +149,34 @@ class SplitContainer(QWidget):
def close_all(self) -> None: def close_all(self) -> None:
for pane in self.panes: for pane in self.panes:
pane.close_connection() 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())

123
app/workspace.py Normal file
View File

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

158
tests/test_workspace.py Normal file
View File

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