refactor: decouper main_window.py en controllers/services/ui
main_window.py concentrait la fenetre principale, l'arbre des sessions, les 4 classes d'onglets, la detection de commandes ssh et le multi-send : 983 lignes et une seule classe qui pilotait toute l'application. - app/services/ssh_target.py : detection d'une cible ssh dans un terminal (pur, sans dependance Qt) - app/ui/tabs.py : BaseTerminalTab / SessionTabWidget / SFTPTabWidget / LocalTerminalTabWidget (deplaces tels quels) - app/controllers/session_controller.py : panneau lateral (arbre sessions + terminaux locaux) et CRUD des sessions - app/controllers/connection_controller.py : cycle de vie des onglets (ouverture, connexion rapide, renommage, fermeture) - app/controllers/broadcast_manager.py : bouton et logique du mode Multi-Send main_window.py ne fait plus que construire ces composants et les relier par signaux (983 -> ~200 lignes). Comportement inchange ; MainWindow.tabs et detect_ssh_target restent importables depuis app.main_window pour la compatibilite avec les tests existants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
0
app/controllers/__init__.py
Normal file
0
app/controllers/__init__.py
Normal file
47
app/controllers/broadcast_manager.py
Normal file
47
app/controllers/broadcast_manager.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Envoi simultane (« Multi-Send ") : une frappe faite dans un onglet terminal part
|
||||
aussi vers tous les autres onglets terminal ouverts, quand le mode est actif."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import QToolButton, QWidget
|
||||
|
||||
|
||||
class BroadcastManager(QObject):
|
||||
"""Ne connait pas le QTabWidget : `tabs_provider` lui fournit les onglets courants,
|
||||
ce qui la garde independante de ConnectionController."""
|
||||
|
||||
def __init__(self, tabs_provider: Callable[[], Iterable[QWidget]], parent=None):
|
||||
super().__init__(parent)
|
||||
self._tabs_provider = tabs_provider
|
||||
|
||||
self.button = QToolButton()
|
||||
self.button.setText("⇉ Multi")
|
||||
self.button.setCheckable(True)
|
||||
self.button.setToolTip(
|
||||
"Envoi simultane : ce que vous tapez part vers tous les onglets terminal ouverts"
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.button.isChecked()
|
||||
|
||||
def register(self, tab) -> None:
|
||||
"""A appeler pour chaque nouvel onglet terminal (SSH ou local) susceptible
|
||||
d'emettre et de recevoir des donnees diffusees."""
|
||||
tab.terminal.data_to_send.connect(lambda data, source=tab: self._broadcast(data, source))
|
||||
|
||||
def _broadcast(self, data: bytes, source) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
# import tardif : evite un cycle (app.ui.tabs importe des dialogues, pas ce module)
|
||||
from app.ui.tabs import BaseTerminalTab
|
||||
|
||||
for tab in self._tabs_provider():
|
||||
if tab is source or not isinstance(tab, BaseTerminalTab):
|
||||
continue
|
||||
if tab.connection:
|
||||
# on ecrit directement dans la connexion : passer par le terminal
|
||||
# relancerait la diffusion et boucherait a l'infini
|
||||
tab.connection.send(data)
|
||||
183
app/controllers/connection_controller.py
Normal file
183
app/controllers/connection_controller.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""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, 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.controllers.broadcast_manager import BroadcastManager
|
||||
from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget
|
||||
|
||||
|
||||
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 open_session(self, session: Session) -> None:
|
||||
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 = SessionTabWidget(session, secret)
|
||||
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."""
|
||||
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)
|
||||
index = self.tabs.addTab(tab, f"📁 {session.name}")
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None:
|
||||
tab = LocalTerminalTabWidget(shell)
|
||||
tab.sftp_requested.connect(self.quick_sftp)
|
||||
self.broadcast.register(tab)
|
||||
label = SHELL_LABELS.get(shell, shell)
|
||||
index = self.tabs.addTab(tab, f"💻 {label}")
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
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",
|
||||
)
|
||||
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
|
||||
menu = QMenu(self._parent_widget)
|
||||
act_rename = menu.addAction("Renommer... (double-clic)")
|
||||
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)
|
||||
|
||||
def _on_tab_close(self, index: int) -> None:
|
||||
widget = self.tabs.widget(index)
|
||||
if 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, (BaseTerminalTab, SFTPTabWidget)):
|
||||
widget.close_connection()
|
||||
232
app/controllers/session_controller.py
Normal file
232
app/controllers/session_controller.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Panneau lateral : arbre des sessions SSH enregistrees + terminaux locaux, et
|
||||
leur CRUD (creer / modifier / dupliquer / supprimer). N'ouvre aucune connexion
|
||||
elle-meme : elle emet des signaux que ConnectionController ecoute."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import uuid
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, QObject
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QTreeWidget, QTreeWidgetItem,
|
||||
QMenu, QMessageBox,
|
||||
)
|
||||
|
||||
from app.session_manager import SessionStore, Session
|
||||
from app.key_manager import KeyManager
|
||||
from app.local_terminal import SHELL_LABELS
|
||||
from app.dialogs.session_dialog import SessionDialog
|
||||
|
||||
SESSION_ROLE = Qt.UserRole + 10
|
||||
LOCAL_SHELL_ROLE = Qt.UserRole + 11
|
||||
|
||||
|
||||
class SessionController(QObject):
|
||||
open_session_requested = Signal(object) # Session
|
||||
open_sftp_requested = Signal(object) # Session
|
||||
open_local_shell_requested = Signal(str) # identifiant de shell
|
||||
quick_sftp_requested = Signal()
|
||||
manage_keys_requested = Signal()
|
||||
manage_known_hosts_requested = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_store: SessionStore,
|
||||
key_manager: KeyManager,
|
||||
broadcast_button: QToolButton,
|
||||
parent_widget: QWidget | None = None,
|
||||
):
|
||||
super().__init__(parent_widget)
|
||||
self.session_store = session_store
|
||||
self.key_manager = key_manager
|
||||
self._parent_widget = parent_widget
|
||||
|
||||
self.widget = QWidget(parent_widget)
|
||||
layout = QVBoxLayout(self.widget)
|
||||
layout.setContentsMargins(6, 6, 6, 6)
|
||||
layout.setSpacing(6)
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
new_btn = QToolButton()
|
||||
new_btn.setObjectName("primary")
|
||||
new_btn.setText("+ Session")
|
||||
new_btn.clicked.connect(self.new_session)
|
||||
toolbar.addWidget(new_btn)
|
||||
|
||||
term_btn = QToolButton()
|
||||
term_btn.setText("💻 Terminal")
|
||||
term_btn.setPopupMode(QToolButton.InstantPopup)
|
||||
term_menu = QMenu(term_btn)
|
||||
for shell_id, label in SHELL_LABELS.items():
|
||||
term_menu.addAction(label, lambda s=shell_id: self.open_local_shell_requested.emit(s))
|
||||
term_btn.setMenu(term_menu)
|
||||
toolbar.addWidget(term_btn)
|
||||
|
||||
toolbar.addWidget(broadcast_button)
|
||||
|
||||
sftp_btn = QToolButton()
|
||||
sftp_btn.setText("📁 SFTP")
|
||||
sftp_btn.setToolTip("Ouvrir un gestionnaire de fichiers SFTP (sans session enregistree)")
|
||||
sftp_btn.clicked.connect(self.quick_sftp_requested.emit)
|
||||
toolbar.addWidget(sftp_btn)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
manage_btn = QToolButton()
|
||||
manage_btn.setText("⚙")
|
||||
manage_btn.setToolTip("Cles SSH et hotes connus")
|
||||
manage_btn.setPopupMode(QToolButton.InstantPopup)
|
||||
manage_menu = QMenu(manage_btn)
|
||||
manage_menu.addAction("Gestionnaire de cles SSH...", self.manage_keys_requested.emit)
|
||||
manage_menu.addAction("Gestionnaire des hotes connus...", self.manage_known_hosts_requested.emit)
|
||||
manage_btn.setMenu(manage_menu)
|
||||
toolbar.addWidget(manage_btn)
|
||||
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.itemDoubleClicked.connect(self._on_tree_double_click)
|
||||
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.tree.customContextMenuRequested.connect(self._on_tree_context_menu)
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
# Sans minimum explicite, la largeur plancher est imposee par le contenu
|
||||
# (les noms de sessions), ce qui empeche de retrecir vraiment le panneau.
|
||||
self.widget.setMinimumWidth(120)
|
||||
self.tree.setMinimumWidth(0)
|
||||
|
||||
self.reload()
|
||||
|
||||
# ---------- arbre ----------
|
||||
def reload(self) -> None:
|
||||
self.tree.clear()
|
||||
|
||||
locals_header = QTreeWidgetItem(["💻 Terminaux locaux"])
|
||||
locals_header.setFlags(locals_header.flags() & ~Qt.ItemIsSelectable)
|
||||
font = locals_header.font(0)
|
||||
font.setBold(True)
|
||||
locals_header.setFont(0, font)
|
||||
self.tree.addTopLevelItem(locals_header)
|
||||
for shell, label in SHELL_LABELS.items():
|
||||
child = QTreeWidgetItem([f" {label}"])
|
||||
child.setData(0, LOCAL_SHELL_ROLE, shell)
|
||||
locals_header.addChild(child)
|
||||
|
||||
sessions_header = QTreeWidgetItem(["🔐 Sessions SSH"])
|
||||
sessions_header.setFlags(sessions_header.flags() & ~Qt.ItemIsSelectable)
|
||||
sessions_header.setFont(0, font)
|
||||
self.tree.addTopLevelItem(sessions_header)
|
||||
|
||||
folder_items: dict[str, QTreeWidgetItem] = {}
|
||||
|
||||
def get_folder(path: str) -> QTreeWidgetItem:
|
||||
if not path:
|
||||
return sessions_header
|
||||
if path in folder_items:
|
||||
return folder_items[path]
|
||||
parent_path, _, name = path.rpartition("/")
|
||||
parent_item = get_folder(parent_path)
|
||||
item = QTreeWidgetItem([f"📁 {name}"])
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsSelectable)
|
||||
parent_item.addChild(item)
|
||||
folder_items[path] = item
|
||||
return item
|
||||
|
||||
for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
|
||||
parent = get_folder(session.group)
|
||||
item = QTreeWidgetItem([f"🖥 {session.name} ({session.host})"])
|
||||
item.setData(0, SESSION_ROLE, session.id)
|
||||
parent.addChild(item)
|
||||
|
||||
if not self.session_store.sessions:
|
||||
hint = QTreeWidgetItem([" (aucune session — bouton « + Session » ou « 📁 SFTP »)"])
|
||||
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
|
||||
hint.setForeground(0, Qt.gray)
|
||||
sessions_header.addChild(hint)
|
||||
|
||||
self.tree.expandAll()
|
||||
|
||||
def _session_from_item(self, item: QTreeWidgetItem) -> Session | None:
|
||||
if not item:
|
||||
return None
|
||||
session_id = item.data(0, SESSION_ROLE)
|
||||
if not session_id:
|
||||
return None
|
||||
return next((s for s in self.session_store.sessions if s.id == session_id), None)
|
||||
|
||||
def _shell_from_item(self, item: QTreeWidgetItem) -> str | None:
|
||||
if not item:
|
||||
return None
|
||||
return item.data(0, LOCAL_SHELL_ROLE)
|
||||
|
||||
def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None:
|
||||
session = self._session_from_item(item)
|
||||
if session:
|
||||
self.open_session_requested.emit(session)
|
||||
return
|
||||
shell = self._shell_from_item(item)
|
||||
if shell:
|
||||
self.open_local_shell_requested.emit(shell)
|
||||
|
||||
def _on_tree_context_menu(self, pos) -> None:
|
||||
item = self.tree.itemAt(pos)
|
||||
menu = QMenu(self.widget)
|
||||
session = self._session_from_item(item) if item else None
|
||||
shell = self._shell_from_item(item) if item else None
|
||||
|
||||
if session:
|
||||
connect_action = menu.addAction("🖥 Ouvrir un terminal SSH")
|
||||
sftp_action = menu.addAction("📁 Ouvrir en SFTP (gestionnaire de fichiers)")
|
||||
menu.addSeparator()
|
||||
edit_action = menu.addAction("Modifier...")
|
||||
duplicate_action = menu.addAction("Dupliquer")
|
||||
delete_action = menu.addAction("Supprimer")
|
||||
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||
if action == connect_action:
|
||||
self.open_session_requested.emit(session)
|
||||
elif action == sftp_action:
|
||||
self.open_sftp_requested.emit(session)
|
||||
elif action == edit_action:
|
||||
self.edit_session(session)
|
||||
elif action == duplicate_action:
|
||||
self.duplicate_session(session)
|
||||
elif action == delete_action:
|
||||
self.delete_session(session)
|
||||
elif shell:
|
||||
open_action = menu.addAction("Ouvrir")
|
||||
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||
if action == open_action:
|
||||
self.open_local_shell_requested.emit(shell)
|
||||
else:
|
||||
new_action = menu.addAction("Nouvelle session...")
|
||||
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||
if action == new_action:
|
||||
self.new_session()
|
||||
|
||||
# ---------- CRUD ----------
|
||||
def new_session(self) -> None:
|
||||
dialog = SessionDialog(self.key_manager, parent=self._parent_widget)
|
||||
if dialog.exec():
|
||||
self.session_store.add(dialog.result_session())
|
||||
self.reload()
|
||||
|
||||
def edit_session(self, session: Session) -> None:
|
||||
dialog = SessionDialog(self.key_manager, session=session, parent=self._parent_widget)
|
||||
if dialog.exec():
|
||||
self.session_store.update(dialog.result_session())
|
||||
self.reload()
|
||||
|
||||
def duplicate_session(self, session: Session) -> None:
|
||||
new_session = copy.deepcopy(session)
|
||||
new_session.id = str(uuid.uuid4())
|
||||
new_session.name = f"{session.name} (copie)"
|
||||
self.session_store.add(new_session)
|
||||
self.reload()
|
||||
|
||||
def delete_session(self, session: Session) -> None:
|
||||
if QMessageBox.question(
|
||||
self._parent_widget, "Supprimer", f"Supprimer la session '{session.name}' ?"
|
||||
) == QMessageBox.Yes:
|
||||
self.session_store.delete(session.id)
|
||||
self.reload()
|
||||
Reference in New Issue
Block a user