Files
pyssh-client/app/sftp_panel.py
Florian fd848c2025 feat: rendre les operations SFTP asynchrones
RemoteFilePanel appelait sftp.listdir_attr/mkdir/rename/remove/chmod/get
directement depuis le thread GUI : un dossier distant lent ou une latence
reseau gelait toute l'interface. Seuls upload/download passaient deja par un
thread dedie (TransferWorker), et celui-ci reutilisait le meme SFTPClient que
la navigation - paramiko.SFTPClient n'est pas concu pour etre pilote par
plusieurs threads a la fois.

- app/sftp/worker.py : SftpWorker, un thread proprietaire exclusif d'un canal
  SFTP, avec une file de jobs (callables) et les resultats renvoyes par signal
  Qt (marshalling automatique vers le thread GUI).
- RemoteFilePanel soumet desormais toutes ses operations via ce worker avec
  callbacks succes/erreur ; un compteur de generation ignore les reponses de
  navigations devenues obsoletes (double-clic rapide, edition puis navigation
  ailleurs pendant le renvoi).
- TransferWorker ouvre son propre canal SFTP (`sftp_factory`) au lieu de
  partager celui de la navigation : un gros transfert ne bloque plus le
  listage de dossiers. SFTPPanel(sftp, transfer_sftp_factory=...) permet
  d'injecter ce second canal (paramiko.SFTPClient.from_transport) ; a defaut,
  le canal de navigation est reutilise tel quel (cas des tests).
- SessionTabWidget/SFTPTabWidget ouvrent ce second canal et appellent
  SFTPPanel.shutdown() a la fermeture de l'onglet (arrete proprement les
  threads avant destruction des widgets).

tests/test_sftp.py adapte pour pomper la boucle d'evenements Qt apres les
operations desormais asynchrones (meme couverture, timing different).

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

873 lines
34 KiB
Python

"""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 tempfile
import stat
from datetime import datetime
from pathlib import Path
import paramiko
from PySide6.QtCore import Qt, QThread, Signal, QMimeData, QPoint, QFileSystemWatcher
from PySide6.QtGui import QDrag, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QToolButton, QTreeWidget,
QTreeWidgetItem, QSplitter, QLabel, QMessageBox, QInputDialog, QProgressDialog,
QAbstractItemView, QMenu, QHeaderView,
)
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 IOError:
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"<b>{title}</b>")
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 IOError 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 IOError 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()