"""Client SFTP integre facon WinSCP : double panneau, colonnes triables, glisser-deposer, renommage, permissions, et file de transferts avec progression globale.""" from __future__ import annotations import json import os import posixpath import stat import tempfile from datetime import datetime from pathlib import Path import paramiko from PySide6.QtCore import QFileSystemWatcher, QMimeData, QPoint, Qt, QThread, Signal from PySide6.QtGui import QDrag, QKeySequence, QShortcut from PySide6.QtWidgets import ( QAbstractItemView, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QMenu, QMessageBox, QProgressDialog, QSplitter, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from app.sftp.worker import SftpWorker MIME_TYPE = "application/x-pyssh-files" NAME_ROLE = Qt.UserRole ISDIR_ROLE = Qt.UserRole + 1 SIZE_ROLE = Qt.UserRole + 2 MTIME_ROLE = Qt.UserRole + 3 PARENT_ROLE = Qt.UserRole + 4 # marque l'entree ".." def human_size(n: int) -> str: value = float(n) for unit in ("o", "Ko", "Mo", "Go", "To"): if value < 1024: return f"{value:.0f} {unit}" if unit == "o" else f"{value:.1f} {unit}" value /= 1024 return f"{value:.1f} Po" def format_time(ts: float) -> str: try: return datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M") except (OSError, ValueError, OverflowError): return "" # ---------------------------------------------------------------------------- # Transferts # ---------------------------------------------------------------------------- class TransferJob: def __init__(self, direction: str, local_path: str, remote_path: str, is_dir: bool): self.direction = direction # "upload" | "download" self.local_path = local_path self.remote_path = remote_path self.is_dir = is_dir class TransferWorker(QThread): """Execute une file de transferts sequentiellement, dans son propre thread. Ouvre son propre canal SFTP (via `sftp_factory`) plutot que de reutiliser celui de la navigation : un gros transfert ne doit jamais faire attendre un listage de dossier, et paramiko.SFTPClient n'est pas concu pour etre partage entre threads. Quand `close_when_done` est faux (aucun canal dedie disponible), le canal fourni est reutilise tel quel et n'est pas ferme en fin de transfert. """ file_started = Signal(str, int, int) # nom, index, total file_progress = Signal(int, int) # octets transferes, taille totale finished_all = Signal(int, list) # nb reussis, liste d'erreurs def __init__(self, sftp_factory, jobs: list[TransferJob], close_when_done: bool = False): super().__init__() self.sftp_factory = sftp_factory self.sftp: paramiko.SFTPClient | None = None self.jobs = jobs self.close_when_done = close_when_done self._cancel = False self._done = 0 self._errors: list[str] = [] def cancel(self) -> None: self._cancel = True def run(self) -> None: try: self.sftp = self.sftp_factory() except Exception as exc: # noqa: BLE001 self.finished_all.emit(0, [f"Impossible d'ouvrir le canal SFTP : {exc}"]) return try: total = len(self.jobs) for index, job in enumerate(self.jobs, start=1): if self._cancel: break label = os.path.basename(job.local_path) or job.local_path self.file_started.emit(label, index, total) try: if job.direction == "upload": if job.is_dir: self._upload_dir(job.local_path, job.remote_path) else: self.sftp.put(job.local_path, job.remote_path, callback=self._cb) else: if job.is_dir: self._download_dir(job.remote_path, job.local_path) else: self.sftp.get(job.remote_path, job.local_path, callback=self._cb) self._done += 1 except Exception as exc: # noqa: BLE001 self._errors.append(f"{label} : {exc}") self.finished_all.emit(self._done, self._errors) finally: if self.close_when_done: try: self.sftp.close() except Exception: pass def _cb(self, transferred: int, total: int) -> None: self.file_progress.emit(transferred, total) def _upload_dir(self, local_dir: str, remote_dir: str) -> None: try: self.sftp.mkdir(remote_dir) except OSError: pass for entry in os.listdir(local_dir): if self._cancel: return lp = os.path.join(local_dir, entry) rp = posixpath.join(remote_dir, entry) if os.path.isdir(lp): self._upload_dir(lp, rp) else: self.sftp.put(lp, rp, callback=self._cb) def _download_dir(self, remote_dir: str, local_dir: str) -> None: Path(local_dir).mkdir(parents=True, exist_ok=True) for attr in self.sftp.listdir_attr(remote_dir): if self._cancel: return rp = posixpath.join(remote_dir, attr.filename) lp = os.path.join(local_dir, attr.filename) if stat.S_ISDIR(attr.st_mode): self._download_dir(rp, lp) else: self.sftp.get(rp, lp, callback=self._cb) # ---------------------------------------------------------------------------- # Panneaux de fichiers # ---------------------------------------------------------------------------- class FileItem(QTreeWidgetItem): """Ligne de fichier avec le tri attendu d'un gestionnaire de fichiers : ".." toujours en tete, puis les dossiers, puis les fichiers ; taille et date comparees numeriquement (et non comme du texte).""" SORT_VALUE_ROLE = Qt.UserRole + 10 def __lt__(self, other: QTreeWidgetItem) -> bool: tree = self.treeWidget() column = tree.sortColumn() if tree else 0 descending = bool( tree and tree.header().sortIndicatorOrder() == Qt.DescendingOrder ) def pinned(result: bool) -> bool: # Qt inverse le resultat en tri descendant : on le compense pour que # ".." et les dossiers gardent leur place quel que soit le sens du tri. return not result if descending else result self_parent = bool(self.data(0, PARENT_ROLE)) other_parent = bool(other.data(0, PARENT_ROLE)) if self_parent != other_parent: return pinned(self_parent) self_dir = bool(self.data(0, ISDIR_ROLE)) other_dir = bool(other.data(0, ISDIR_ROLE)) if self_dir != other_dir: return pinned(self_dir) self_value = self.data(column, self.SORT_VALUE_ROLE) other_value = other.data(column, self.SORT_VALUE_ROLE) if self_value is not None and other_value is not None: return self_value < other_value return self.text(column).lower() < other.text(column).lower() class FileTree(QTreeWidget): """Arbre de fichiers avec glisser-deposer entre panneaux.""" files_dropped = Signal(dict) def __init__(self, side: str, parent=None): super().__init__(parent) self.side = side self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setRootIsDecorated(False) self.setSortingEnabled(True) self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDrop) self.setHeaderLabels(["Nom", "Taille", "Modifie", "Droits"]) # sans tri initial explicite, les lignes resteraient dans l'ordre d'insertion self.sortByColumn(0, Qt.AscendingOrder) self.header().setSectionResizeMode(0, QHeaderView.Stretch) self.setColumnWidth(1, 90) self.setColumnWidth(2, 130) self.setColumnWidth(3, 100) def startDrag(self, actions) -> None: entries = [] for item in self.selectedItems(): if item.data(0, PARENT_ROLE): continue entries.append({"name": item.data(0, NAME_ROLE), "is_dir": bool(item.data(0, ISDIR_ROLE))}) if not entries: return payload = {"side": self.side, "entries": entries} mime = QMimeData() mime.setData(MIME_TYPE, json.dumps(payload).encode("utf-8")) drag = QDrag(self) drag.setMimeData(mime) drag.exec(Qt.CopyAction) def dragEnterEvent(self, event) -> None: if event.mimeData().hasFormat(MIME_TYPE): event.acceptProposedAction() def dragMoveEvent(self, event) -> None: if event.mimeData().hasFormat(MIME_TYPE): event.acceptProposedAction() def dropEvent(self, event) -> None: if not event.mimeData().hasFormat(MIME_TYPE): return payload = json.loads(bytes(event.mimeData().data(MIME_TYPE)).decode("utf-8")) if payload.get("side") == self.side: return # depose sur lui-meme : rien a faire event.acceptProposedAction() self.files_dropped.emit(payload) class FilePanel(QWidget): """Panneau generique (decline en local et distant).""" path_changed = Signal(str) transfer_here = Signal(dict) def __init__(self, title: str, side: str, parent=None): super().__init__(parent) self.side = side layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) layout.setSpacing(4) header = QHBoxLayout() self.title_label = QLabel(f"{title}") header.addWidget(self.title_label) header.addStretch() layout.addLayout(header) nav = QHBoxLayout() self.up_btn = QToolButton(); self.up_btn.setText("⬆"); self.up_btn.setToolTip("Dossier parent") self.refresh_btn = QToolButton(); self.refresh_btn.setText("⟳"); self.refresh_btn.setToolTip("Actualiser (F5)") self.mkdir_btn = QToolButton(); self.mkdir_btn.setText("📁+"); self.mkdir_btn.setToolTip("Nouveau dossier") self.path_edit = QLineEdit() nav.addWidget(self.up_btn) nav.addWidget(self.refresh_btn) nav.addWidget(self.mkdir_btn) nav.addWidget(self.path_edit, 1) layout.addLayout(nav) self.tree = FileTree(side) layout.addWidget(self.tree) self.status = QLabel("") self.status.setObjectName("statusLabel") layout.addWidget(self.status) self.up_btn.clicked.connect(self.go_up) self.refresh_btn.clicked.connect(self.refresh) self.mkdir_btn.clicked.connect(self.make_dir) self.path_edit.returnPressed.connect(lambda: self.navigate(self.path_edit.text())) self.tree.itemDoubleClicked.connect(self._on_double_click) self.tree.files_dropped.connect(self.transfer_here.emit) self.tree.setContextMenuPolicy(Qt.CustomContextMenu) self.tree.customContextMenuRequested.connect(self._context_menu) QShortcut(QKeySequence("F2"), self.tree, activated=self.rename_selected) QShortcut(QKeySequence("F5"), self.tree, activated=self.refresh) QShortcut(QKeySequence(Qt.Key_Delete), self.tree, activated=self.delete_selected) self.current_path = "" # ---------- helpers d'affichage ---------- def _add_row(self, name: str, is_dir: bool, size: int, mtime: float, perms: str) -> None: item = FileItem([ ("📁 " if is_dir else "📄 ") + name, "" if is_dir else human_size(size), format_time(mtime) if mtime else "", perms, ]) item.setData(0, NAME_ROLE, name) item.setData(0, ISDIR_ROLE, is_dir) # tri numerique correct sur la taille et la date item.setData(1, FileItem.SORT_VALUE_ROLE, size) item.setData(2, FileItem.SORT_VALUE_ROLE, mtime) self.tree.addTopLevelItem(item) def _add_parent_row(self) -> None: item = FileItem(["📁 ..", "", "", ""]) item.setData(0, PARENT_ROLE, True) item.setData(0, ISDIR_ROLE, True) self.tree.addTopLevelItem(item) def _on_double_click(self, item: QTreeWidgetItem) -> None: if item.data(0, PARENT_ROLE): self.go_up() return if item.data(0, ISDIR_ROLE): self.navigate(self.join(self.current_path, item.data(0, NAME_ROLE))) def go_up(self) -> None: parent = self.parent_of(self.current_path) if parent and parent != self.current_path: self.navigate(parent) def selected_entries(self) -> list[tuple[str, bool]]: out = [] for item in self.tree.selectedItems(): if item.data(0, PARENT_ROLE): continue out.append((item.data(0, NAME_ROLE), bool(item.data(0, ISDIR_ROLE)))) return out def _context_menu(self, pos: QPoint) -> None: menu = QMenu(self) has_selection = bool(self.selected_entries()) act_transfer = menu.addAction("⬆ Envoyer" if self.side == "local" else "⬇ Recevoir") act_transfer.setEnabled(has_selection) menu.addSeparator() act_rename = menu.addAction("Renommer (F2)") act_rename.setEnabled(has_selection) act_delete = menu.addAction("Supprimer (Suppr)") act_delete.setEnabled(has_selection) act_perms = act_edit = None if self.side == "remote": menu.addSeparator() act_edit = menu.addAction("Editer (renvoi automatique a l'enregistrement)") act_edit.setEnabled(has_selection) act_perms = menu.addAction("Permissions...") act_perms.setEnabled(has_selection) menu.addSeparator() act_mkdir = menu.addAction("Nouveau dossier") act_refresh = menu.addAction("Actualiser (F5)") action = menu.exec(self.tree.viewport().mapToGlobal(pos)) if action == act_transfer: self.transfer_requested() elif action == act_rename: self.rename_selected() elif action == act_delete: self.delete_selected() elif act_edit is not None and action == act_edit: self.edit_selected() elif act_perms is not None and action == act_perms: self.change_permissions() elif action == act_mkdir: self.make_dir() elif action == act_refresh: self.refresh() def transfer_requested(self) -> None: entries = [{"name": n, "is_dir": d} for n, d in self.selected_entries()] if entries: self.transfer_here.emit({"side": self.side, "entries": entries, "_from_button": True}) # ---------- a surcharger ---------- def navigate(self, path: str) -> None: ... def refresh(self) -> None: ... def join(self, a: str, b: str) -> str: ... def parent_of(self, path: str) -> str: ... def make_dir(self) -> None: ... def rename_selected(self) -> None: ... def delete_selected(self) -> None: ... def change_permissions(self) -> None: ... def edit_selected(self) -> None: ... class LocalFilePanel(FilePanel): def __init__(self, parent=None): super().__init__("💻 Local", "local", parent) self.navigate(str(Path.home())) def join(self, a: str, b: str) -> str: return str(Path(a) / b) def parent_of(self, path: str) -> str: return str(Path(path).parent) def navigate(self, path: str) -> None: p = Path(path) if not p.exists() or not p.is_dir(): QMessageBox.warning(self, "Chemin invalide", f"Dossier introuvable : {path}") return self.current_path = str(p) self.path_edit.setText(self.current_path) self.refresh() self.path_changed.emit(self.current_path) def refresh(self) -> None: self.tree.setSortingEnabled(False) self.tree.clear() self._add_parent_row() count = 0 try: for entry in Path(self.current_path).iterdir(): try: st = entry.stat() is_dir = entry.is_dir() self._add_row(entry.name, is_dir, st.st_size, st.st_mtime, stat.filemode(st.st_mode)) count += 1 except OSError: continue except OSError as exc: QMessageBox.warning(self, "Erreur", str(exc)) self.tree.setSortingEnabled(True) self.status.setText(f"{count} element(s)") def make_dir(self) -> None: name, ok = QInputDialog.getText(self, "Nouveau dossier", "Nom du dossier :") if not ok or not name: return try: Path(self.current_path, name).mkdir() self.refresh() except OSError as exc: QMessageBox.critical(self, "Erreur", str(exc)) def rename_selected(self) -> None: entries = self.selected_entries() if not entries: return old_name = entries[0][0] new_name, ok = QInputDialog.getText(self, "Renommer", "Nouveau nom :", text=old_name) if not ok or not new_name or new_name == old_name: return try: Path(self.current_path, old_name).rename(Path(self.current_path, new_name)) self.refresh() except OSError as exc: QMessageBox.critical(self, "Erreur", str(exc)) def delete_selected(self) -> None: entries = self.selected_entries() if not entries: return if QMessageBox.question( self, "Supprimer", f"Supprimer definitivement {len(entries)} element(s) en local ?", ) != QMessageBox.Yes: return import shutil for name, is_dir in entries: target = Path(self.current_path, name) try: if is_dir: shutil.rmtree(target) else: target.unlink() except OSError as exc: QMessageBox.critical(self, "Erreur", f"{name} : {exc}") self.refresh() class RemoteFilePanel(FilePanel): """Toutes les operations reseau passent par `worker` (un `SftpWorker` dedie a ce panneau) : aucun appel a `self.sftp` ne doit avoir lieu directement depuis le thread GUI, pour ne jamais geler l'interface et pour respecter le fait qu'un SFTPClient paramiko ne doit etre pilote que par un seul thread a la fois.""" def __init__(self, sftp: paramiko.SFTPClient, worker: SftpWorker, parent=None): super().__init__("🌐 Distant", "remote", parent) self.sftp = sftp self.worker = worker self.worker.result_ready.connect(self._on_result) self._callbacks: dict[int, tuple] = {} self._nav_generation = 0 # fichiers ouverts pour edition : chemin local temporaire -> chemin distant self._edited: dict[str, str] = {} self._watcher = QFileSystemWatcher(self) self._watcher.fileChanged.connect(self._on_edited_file_changed) self.status.setText("Connexion...") def on_home_success(home) -> None: self.navigate(home or "/") def on_home_error(_exc) -> None: self.navigate("/") self._run(lambda: self.sftp.normalize("."), on_home_success, on_home_error) def join(self, a: str, b: str) -> str: return posixpath.join(a, b) def parent_of(self, path: str) -> str: return posixpath.dirname(path.rstrip("/")) or "/" # ---------- soumission asynchrone ---------- def _run(self, fn, on_success=None, on_error=None) -> None: request_id = self.worker.submit(fn) self._callbacks[request_id] = (on_success, on_error) def _on_result(self, request_id: int, result, error: Exception | None) -> None: callbacks = self._callbacks.pop(request_id, None) if callbacks is None: return on_success, on_error = callbacks if error is not None: if on_error: on_error(error) else: QMessageBox.critical(self, "Erreur SFTP", str(error)) elif on_success: on_success(result) def navigate(self, path: str) -> None: self._nav_generation += 1 generation = self._nav_generation self.status.setText("Chargement...") def on_success(attrs) -> None: if generation != self._nav_generation: return # une navigation plus recente a ete lancee entre-temps self.current_path = path self.path_edit.setText(path) self._populate(attrs) self.path_changed.emit(path) def on_error(exc) -> None: if generation != self._nav_generation: return self.status.setText("") QMessageBox.warning(self, "Erreur SFTP", f"{path} : {exc}") self._run(lambda: self.sftp.listdir_attr(path), on_success, on_error) def refresh(self) -> None: if self.current_path: self.navigate(self.current_path) def _populate(self, attrs) -> None: self.tree.setSortingEnabled(False) self.tree.clear() self._add_parent_row() for attr in attrs: is_dir = stat.S_ISDIR(attr.st_mode) self._add_row( attr.filename, is_dir, attr.st_size or 0, attr.st_mtime or 0, stat.filemode(attr.st_mode), ) self.tree.setSortingEnabled(True) self.status.setText(f"{len(attrs)} element(s)") def make_dir(self) -> None: name, ok = QInputDialog.getText(self, "Nouveau dossier", "Nom du dossier :") if not ok or not name: return path = self.join(self.current_path, name) def on_error(exc) -> None: QMessageBox.critical(self, "Erreur", str(exc)) self._run(lambda: self.sftp.mkdir(path), lambda _r: self.refresh(), on_error) def rename_selected(self) -> None: entries = self.selected_entries() if not entries: return old_name = entries[0][0] new_name, ok = QInputDialog.getText(self, "Renommer", "Nouveau nom :", text=old_name) if not ok or not new_name or new_name == old_name: return old_path = self.join(self.current_path, old_name) new_path = self.join(self.current_path, new_name) def on_error(exc) -> None: QMessageBox.critical(self, "Erreur", str(exc)) self._run(lambda: self.sftp.rename(old_path, new_path), lambda _r: self.refresh(), on_error) def delete_selected(self) -> None: entries = self.selected_entries() if not entries: return if QMessageBox.question( self, "Supprimer", f"Supprimer definitivement {len(entries)} element(s) sur le serveur ?", ) != QMessageBox.Yes: return current_path = self.current_path def do_delete() -> list[str]: errors = [] for name, is_dir in entries: path = self.join(current_path, name) try: if is_dir: self._rmdir_recursive(path) else: self.sftp.remove(path) except OSError as exc: errors.append(f"{name} : {exc}") return errors def on_success(errors: list[str]) -> None: if errors: QMessageBox.critical(self, "Erreur", "\n".join(errors)) self.refresh() self._run(do_delete, on_success) def _rmdir_recursive(self, path: str) -> None: """Appele uniquement depuis le thread du worker (jamais directement sur le thread GUI) : parcourt et supprime un dossier distant recursivement.""" for attr in self.sftp.listdir_attr(path): sub = posixpath.join(path, attr.filename) if stat.S_ISDIR(attr.st_mode): self._rmdir_recursive(sub) else: self.sftp.remove(sub) self.sftp.rmdir(path) # ---------- edition en place ---------- def edit_selected(self) -> None: """Telecharge le fichier, l'ouvre dans l'editeur par defaut du systeme et le renvoie automatiquement a chaque enregistrement (facon WinSCP).""" entries = [(n, d) for n, d in self.selected_entries() if not d] if not entries: QMessageBox.information(self, "Edition", "Selectionnez un fichier (pas un dossier).") return name = entries[0][0] remote_path = self.join(self.current_path, name) local_dir = Path(tempfile.gettempdir()) / "pyssh-edit" local_dir.mkdir(parents=True, exist_ok=True) local_path = local_dir / name self.status.setText(f"Telechargement de {name}...") def on_success(_result) -> None: self._edited[str(local_path)] = remote_path self._watcher.addPath(str(local_path)) self.status.setText(f"Edition de {name} — enregistrez pour renvoyer sur le serveur") try: os.startfile(str(local_path)) # ouvre avec l'application associee except OSError as exc: QMessageBox.warning( self, "Ouverture impossible", f"Le fichier a ete telecharge dans :\n{local_path}\n\n" f"Ouvrez-le manuellement ; il sera renvoye a chaque enregistrement.\n({exc})", ) def on_error(exc) -> None: self.status.setText("") QMessageBox.critical(self, "Erreur", f"Telechargement impossible : {exc}") self._run(lambda: self.sftp.get(remote_path, str(local_path)), on_success, on_error) def _on_edited_file_changed(self, local_path: str) -> None: remote_path = self._edited.get(local_path) if not remote_path: return def on_error(exc) -> None: self.status.setText(f"Echec du renvoi : {exc}") def on_success(_result) -> None: # Beaucoup d'editeurs enregistrent en remplacant le fichier : le chemin # surveille disparait alors du watcher, il faut le reinscrire pour les # enregistrements suivants. if local_path not in self._watcher.files() and Path(local_path).exists(): self._watcher.addPath(local_path) self._refresh_after_edit(remote_path) self._run(lambda: self.sftp.put(local_path, remote_path), on_success, on_error) def _refresh_after_edit(self, remote_path: str) -> None: """Comme refresh(), mais affiche un message de confirmation specifique une fois le listage revenu, au lieu du compteur d'elements habituel.""" self._nav_generation += 1 generation = self._nav_generation path = self.current_path def on_success(attrs) -> None: if generation != self._nav_generation: return self._populate(attrs) self.status.setText(f"↑ {posixpath.basename(remote_path)} renvoye sur le serveur") def on_error(exc) -> None: if generation != self._nav_generation: return QMessageBox.warning(self, "Erreur SFTP", f"{path} : {exc}") self._run(lambda: self.sftp.listdir_attr(path), on_success, on_error) def change_permissions(self) -> None: entries = self.selected_entries() if not entries: return name = entries[0][0] path = self.join(self.current_path, name) def on_stat_error(exc) -> None: QMessageBox.critical(self, "Erreur", str(exc)) def on_stat_success(attr) -> None: current = stat.S_IMODE(attr.st_mode) text, ok = QInputDialog.getText( self, "Permissions", f"Droits octaux pour {name} :", text=f"{current:03o}", ) if not ok or not text: return try: mode = int(text.strip(), 8) except ValueError: QMessageBox.warning(self, "Valeur invalide", "Entrez une valeur octale, par exemple 644 ou 755.") return self._apply_chmod(entries, mode) self._run(lambda: self.sftp.stat(path), on_stat_success, on_stat_error) def _apply_chmod(self, entries: list[tuple[str, bool]], mode: int) -> None: current_path = self.current_path def do_chmod() -> list[str]: errors = [] for entry_name, _ in entries: try: self.sftp.chmod(self.join(current_path, entry_name), mode) except OSError as exc: errors.append(f"{entry_name} : {exc}") return errors def on_success(errors: list[str]) -> None: if errors: QMessageBox.critical(self, "Erreur", "\n".join(errors)) self.refresh() self._run(do_chmod, on_success) # ---------------------------------------------------------------------------- # Conteneur double panneau # ---------------------------------------------------------------------------- class SFTPPanel(QWidget): """`transfer_sftp_factory`, si fourni, ouvre un second canal SFTP reserve aux transferts (upload/download), independant du canal de navigation : un gros transfert ne bloque alors jamais le listage de dossiers. A defaut (ex. client SFTP de test sans transport reel), le canal de navigation est reutilise tel quel.""" def __init__(self, sftp: paramiko.SFTPClient, transfer_sftp_factory=None, parent=None): super().__init__(parent) self.sftp = sftp self._worker: TransferWorker | None = None self._dedicated_transfer_channel = transfer_sftp_factory is not None self._transfer_sftp_factory = transfer_sftp_factory or (lambda: sftp) layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) toolbar = QHBoxLayout() self.upload_btn = QToolButton(); self.upload_btn.setText("⬆ Envoyer") self.download_btn = QToolButton(); self.download_btn.setText("⬇ Recevoir") hint = QLabel(" Glissez-deposez entre les panneaux, ou utilisez F2 (renommer), Suppr, F5.") hint.setObjectName("statusLabel") toolbar.addWidget(self.upload_btn) toolbar.addWidget(self.download_btn) toolbar.addWidget(hint) toolbar.addStretch() layout.addLayout(toolbar) splitter = QSplitter(Qt.Horizontal) self.local = LocalFilePanel() self.remote_worker = SftpWorker(self) self.remote_worker.start() self.remote = RemoteFilePanel(sftp, self.remote_worker) splitter.addWidget(self.local) splitter.addWidget(self.remote) splitter.setSizes([500, 500]) layout.addWidget(splitter, 1) self.upload_btn.clicked.connect(self.local.transfer_requested) self.download_btn.clicked.connect(self.remote.transfer_requested) # un depot venant du panneau local signifie "envoyer", et inversement self.local.transfer_here.connect(self._on_transfer_payload) self.remote.transfer_here.connect(self._on_transfer_payload) def shutdown(self) -> None: """A appeler avant que le panneau ne soit detruit (fermeture de l'onglet) : arrete proprement le worker de navigation et tout transfert en cours.""" if self._worker and self._worker.isRunning(): self._worker.cancel() self._worker.wait(2000) self.remote_worker.stop() self.remote_worker.wait(2000) def _on_transfer_payload(self, payload: dict) -> None: source_side = payload.get("side") entries = payload.get("entries", []) if not entries: return jobs = [] for entry in entries: name, is_dir = entry["name"], entry["is_dir"] if source_side == "local": jobs.append(TransferJob( "upload", self.local.join(self.local.current_path, name), self.remote.join(self.remote.current_path, name), is_dir, )) else: jobs.append(TransferJob( "download", self.local.join(self.local.current_path, name), self.remote.join(self.remote.current_path, name), is_dir, )) self._run_jobs(jobs) def _run_jobs(self, jobs: list[TransferJob]) -> None: if not jobs: return if self._worker and self._worker.isRunning(): QMessageBox.information(self, "Transfert en cours", "Attendez la fin du transfert en cours.") return dialog = QProgressDialog("Preparation...", "Annuler", 0, 100, self) dialog.setWindowTitle("Transfert SFTP") dialog.setWindowModality(Qt.WindowModal) dialog.setMinimumDuration(0) dialog.setAutoClose(False) dialog.setAutoReset(False) worker = TransferWorker( self._transfer_sftp_factory, jobs, close_when_done=self._dedicated_transfer_channel, ) worker.file_started.connect( lambda name, i, total: dialog.setLabelText(f"({i}/{total}) {name}") ) worker.file_progress.connect( lambda done, total: dialog.setValue(int(done * 100 / total) if total else 0) ) def on_finished(done: int, errors: list) -> None: dialog.close() self.local.refresh() self.remote.refresh() if errors: QMessageBox.warning( self, "Transfert termine avec des erreurs", f"{done} element(s) transfere(s).\n\nErreurs :\n" + "\n".join(errors[:10]), ) worker.finished_all.connect(on_finished) dialog.canceled.connect(worker.cancel) self._worker = worker worker.start()