Files
pyssh-client/app/controllers/workspace_manager.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

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

185 lines
7.6 KiB
Python

"""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.controllers.connection_controller import ConnectionController
from app.local_terminal import DEFAULT_SHELL
from app.session_manager import SessionStore
from app.ui.split_pane import LAYOUT_SINGLE, SplitContainer
from app.ui.tabs import LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget
from app.workspace import (
ITEM_LOCAL_SHELL,
ITEM_SESSION_SFTP,
ITEM_SESSION_SSH,
Workspace,
WorkspaceItem,
WorkspaceStore,
WorkspaceTab,
)
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
# strict=True est volontairement evite : un workspace legerement perime
# (enregistre par une version anterieure, ou modifie a la main) peut avoir
# une liste d'items plus courte que le nombre de panneaux - on restaure
# alors ce qui correspond et on laisse les panneaux excedentaires a leur
# terminal local par defaut, plutot que d'abandonner toute la restauration.
for pane, item in zip(container.panes[1:], wtab.items[1:]): # noqa: B905
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