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>
336 lines
15 KiB
Python
336 lines
15 KiB
Python
"""Cycle de vie des onglets (terminal SSH, terminal local, SFTP autonome) :
|
|
ouverture, connexion rapide, renommage, fermeture."""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt, Signal, QObject
|
|
from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog
|
|
|
|
from app.session_manager import SessionStore, Session, AUTH_PASSWORD
|
|
from app.key_manager import KeyManager
|
|
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
|
|
from app.dialogs.session_dialog import SessionDialog
|
|
from app.dialogs.connect_prompt import ask_secret
|
|
from app.services.ssh_target import detect_ssh_target
|
|
from app.openssh.config import OpenSSHHost, session_from_openssh_host
|
|
from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError
|
|
from app.controllers.broadcast_manager import BroadcastManager
|
|
from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget
|
|
from app.ui.split_pane import SplitContainer, PaneSlot, LAYOUT_LABELS
|
|
|
|
|
|
class ConnectionController(QObject):
|
|
"""Possede le QTabWidget central. `session_saved` est emis quand une connexion
|
|
rapide est aussi enregistree comme session, pour que le panneau lateral se
|
|
rafraichisse sans que ce controleur ait besoin de le connaitre."""
|
|
|
|
session_saved = Signal()
|
|
|
|
def __init__(
|
|
self,
|
|
session_store: SessionStore,
|
|
key_manager: KeyManager,
|
|
broadcast: BroadcastManager,
|
|
parent_widget: QWidget | None = None,
|
|
):
|
|
super().__init__(parent_widget)
|
|
self.session_store = session_store
|
|
self.key_manager = key_manager
|
|
self.broadcast = broadcast
|
|
self._parent_widget = parent_widget
|
|
|
|
self.tabs = QTabWidget(parent_widget)
|
|
self.tabs.setTabsClosable(True)
|
|
self.tabs.setMovable(True)
|
|
self.tabs.tabBarDoubleClicked.connect(self.rename_tab)
|
|
self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.tabs.tabBar().customContextMenuRequested.connect(self._on_tab_context_menu)
|
|
self.tabs.tabCloseRequested.connect(self._on_tab_close)
|
|
|
|
# ---------- ouverture ----------
|
|
def _resolve_bastion(self, session: Session) -> tuple[bool, BastionTarget | None, str]:
|
|
"""Renvoie (ok, bastion_target, bastion_password). ok=False signifie que
|
|
l'ouverture doit etre annulee (bastion invalide, ou mot de passe du
|
|
bastion non fourni) ; l'appelant ne doit alors rien ouvrir."""
|
|
try:
|
|
target = resolve_bastion_target(session.bastion, self.session_store)
|
|
except BastionResolutionError as exc:
|
|
QMessageBox.critical(self._parent_widget, "Bastion invalide", str(exc))
|
|
return False, None, ""
|
|
if target is None:
|
|
return True, None, ""
|
|
if target.auth_type != AUTH_PASSWORD:
|
|
return True, target, ""
|
|
text, ok = ask_secret(
|
|
self._parent_widget, "Mot de passe du bastion", f"Mot de passe pour {target.label} (bastion) :",
|
|
)
|
|
if not ok:
|
|
return False, None, ""
|
|
return True, target, text
|
|
|
|
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 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)
|
|
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."""
|
|
tab = self.build_sftp_tab(session)
|
|
if tab is None:
|
|
return
|
|
index = self.tabs.addTab(tab, f"📁 {session.name}")
|
|
self.tabs.setCurrentIndex(index)
|
|
|
|
def open_openssh_host(self, host: OpenSSHHost) -> None:
|
|
"""Connexion directe a un Host de ~/.ssh/config, sans l'enregistrer comme
|
|
session. Un ProxyJump eventuel est resolu et applique via app.bastion."""
|
|
self.open_session(session_from_openssh_host(host))
|
|
|
|
def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None:
|
|
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:
|
|
"""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)
|
|
tab.sftp_requested.connect(self.quick_sftp)
|
|
self.broadcast.register(tab)
|
|
return tab
|
|
|
|
def quick_sftp(self, target: dict | None = None) -> None:
|
|
"""Ouvre un SFTP en saisissant directement les infos de connexion, sans favori.
|
|
|
|
Si l'onglet courant est un terminal ou une commande ssh a ete tapee, l'hote,
|
|
l'utilisateur et le port sont pre-remplis automatiquement."""
|
|
if target is None:
|
|
target = self._current_tab_ssh_target()
|
|
self._quick_connect(sftp=True, target=target)
|
|
|
|
def quick_ssh(self) -> None:
|
|
self._quick_connect(sftp=False, target=self._current_tab_ssh_target())
|
|
|
|
def _current_tab_ssh_target(self) -> dict | None:
|
|
widget = self.tabs.currentWidget()
|
|
if isinstance(widget, SessionTabWidget):
|
|
return {
|
|
"user": widget.session.username,
|
|
"host": widget.session.host,
|
|
"port": widget.session.port,
|
|
}
|
|
if isinstance(widget, BaseTerminalTab):
|
|
return detect_ssh_target(widget.terminal.buffer_text())
|
|
return None
|
|
|
|
def _quick_connect(self, sftp: bool, target: dict | None = None) -> None:
|
|
prefill = None
|
|
if target:
|
|
prefill = Session(
|
|
name=target.get("host", ""),
|
|
host=target.get("host", ""),
|
|
username=target.get("user", ""),
|
|
port=target.get("port", 22),
|
|
)
|
|
dialog = SessionDialog(
|
|
self.key_manager,
|
|
session=prefill,
|
|
parent=self._parent_widget,
|
|
connect_label="Se connecter",
|
|
title="Connexion SFTP" if sftp else "Connexion SSH",
|
|
session_store=self.session_store,
|
|
)
|
|
if not dialog.exec():
|
|
return
|
|
session = dialog.result_session()
|
|
if dialog.should_save():
|
|
self.session_store.add(session)
|
|
self.session_saved.emit()
|
|
if sftp:
|
|
self.open_sftp_session(session)
|
|
else:
|
|
self.open_session(session)
|
|
|
|
# ---------- gestion des onglets ----------
|
|
def rename_tab(self, index: int) -> None:
|
|
if index < 0:
|
|
return
|
|
actuel = self.tabs.tabText(index)
|
|
nouveau, ok = QInputDialog.getText(
|
|
self._parent_widget, "Renommer l'onglet", "Nom de l'onglet :", text=actuel
|
|
)
|
|
if ok and nouveau.strip():
|
|
self.tabs.setTabText(index, nouveau.strip())
|
|
|
|
def _on_tab_context_menu(self, pos) -> None:
|
|
bar = self.tabs.tabBar()
|
|
index = bar.tabAt(pos)
|
|
if index < 0:
|
|
return
|
|
widget = self.tabs.widget(index)
|
|
menu = QMenu(self._parent_widget)
|
|
act_rename = menu.addAction("Renommer... (double-clic)")
|
|
menu.addSeparator()
|
|
|
|
# Diviser : seulement pour un terminal simple (SSH ou local), pas pour
|
|
# un SFTP ni pour un split existant (pas d'imbrication de splits).
|
|
split_actions = {}
|
|
if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
|
|
split_menu = menu.addMenu("Diviser")
|
|
for kind, label in LAYOUT_LABELS.items():
|
|
split_actions[split_menu.addAction(label)] = kind
|
|
menu.addSeparator()
|
|
|
|
act_close = menu.addAction("Fermer cet onglet")
|
|
act_close_others = menu.addAction("Fermer les autres onglets")
|
|
act_close_others.setEnabled(self.tabs.count() > 1)
|
|
|
|
action = menu.exec(bar.mapToGlobal(pos))
|
|
if action == act_rename:
|
|
self.rename_tab(index)
|
|
elif action == act_close:
|
|
self._on_tab_close(index)
|
|
elif action == act_close_others:
|
|
# on ferme par la fin : retirer un onglet decale les suivants
|
|
cible = self.tabs.widget(index)
|
|
for other in range(self.tabs.count() - 1, -1, -1):
|
|
if self.tabs.widget(other) is not cible:
|
|
self._on_tab_close(other)
|
|
elif action in split_actions:
|
|
self.split_tab(index, split_actions[action])
|
|
|
|
def _on_tab_close(self, index: int) -> None:
|
|
widget = self.tabs.widget(index)
|
|
if isinstance(widget, SplitContainer):
|
|
widget.close_all()
|
|
elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
|
|
widget.close_connection()
|
|
self.tabs.removeTab(index)
|
|
|
|
def close_all(self) -> None:
|
|
"""A appeler a la fermeture de l'application : ferme proprement toutes les
|
|
connexions (SSH, locales, SFTP) avant que les widgets ne soient detruits."""
|
|
for i in range(self.tabs.count()):
|
|
widget = self.tabs.widget(i)
|
|
if isinstance(widget, SplitContainer):
|
|
widget.close_all()
|
|
elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
|
|
widget.close_connection()
|
|
|
|
# ---------- split terminal ----------
|
|
def split_tab(self, index: int, layout_kind: str) -> None:
|
|
"""Transforme l'onglet en une grille de terminaux : le contenu actuel
|
|
occupe le premier panneau, les autres recoivent un terminal local par
|
|
defaut (remplacable ensuite via le menu ☰ de chaque panneau)."""
|
|
widget = self.tabs.widget(index)
|
|
if not isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
|
|
return
|
|
title = self.tabs.tabText(index)
|
|
# SplitContainer reparente `widget` dans un de ses panneaux ; Qt retire
|
|
# 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.all_panes_closed.connect(
|
|
lambda remaining, c=container: self._on_split_collapsed(c, remaining)
|
|
)
|
|
container.pane_menu_requested.connect(self._show_pane_menu)
|
|
self.tabs.insertTab(index, container, f"⊞ {title}")
|
|
self.tabs.setCurrentIndex(index)
|
|
|
|
def _on_split_collapsed(self, container: SplitContainer, remaining: QWidget | None) -> None:
|
|
"""Il ne reste plus qu'un panneau : on redeplie vers un onglet normal."""
|
|
index = self.tabs.indexOf(container)
|
|
if index < 0:
|
|
return
|
|
self.tabs.removeTab(index)
|
|
if remaining is None:
|
|
return
|
|
title = self.default_title_for(remaining)
|
|
self.tabs.insertTab(index, remaining, title)
|
|
self.tabs.setCurrentIndex(index)
|
|
|
|
@staticmethod
|
|
def default_title_for(widget: QWidget) -> str:
|
|
if isinstance(widget, SessionTabWidget):
|
|
return f"🖥 {widget.session.name}"
|
|
if isinstance(widget, SFTPTabWidget):
|
|
return f"📁 {widget.session.name}"
|
|
if isinstance(widget, LocalTerminalTabWidget):
|
|
return f"💻 {SHELL_LABELS.get(widget.shell, widget.shell)}"
|
|
return "Onglet"
|
|
|
|
def _movable_tabs(self) -> list[tuple[int, str, QWidget]]:
|
|
"""Onglets de premier niveau deplacables vers un panneau (ni SFTP, ni
|
|
un split - on ne gere pas les deplacements panneau-a-panneau)."""
|
|
result = []
|
|
for i in range(self.tabs.count()):
|
|
widget = self.tabs.widget(i)
|
|
if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
|
|
result.append((i, self.tabs.tabText(i), widget))
|
|
return result
|
|
|
|
def _show_pane_menu(self, pane: PaneSlot, container: SplitContainer) -> None:
|
|
menu = QMenu(self._parent_widget)
|
|
term_menu = menu.addMenu("Terminal local")
|
|
shell_actions = {}
|
|
for shell_id, label in SHELL_LABELS.items():
|
|
shell_actions[term_menu.addAction(label)] = shell_id
|
|
|
|
movable = self._movable_tabs()
|
|
move_actions = {}
|
|
if movable:
|
|
menu.addSeparator()
|
|
move_menu = menu.addMenu("Deplacer un onglet ici")
|
|
for index, title, widget in movable:
|
|
move_actions[move_menu.addAction(title)] = (index, widget)
|
|
else:
|
|
hint = menu.addAction("(aucun autre onglet a deplacer ici)")
|
|
hint.setEnabled(False)
|
|
|
|
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]))
|
|
elif action in move_actions:
|
|
index, moved_widget = move_actions[action]
|
|
self.move_tab_into_pane(index, container, pane)
|
|
|
|
def move_tab_into_pane(self, tab_index: int, container: SplitContainer, pane: PaneSlot) -> None:
|
|
"""Deplace l'onglet de premier niveau `tab_index` dans `pane` : sa
|
|
connexion continue de vivre, seul son emplacement visuel change."""
|
|
widget = self.tabs.widget(tab_index)
|
|
self.tabs.removeTab(tab_index) # ne ferme pas la connexion : elle continue dans le panneau
|
|
container.replace_pane_content(pane, widget)
|