Files
pyssh-client/app/sftp_panel.py
snoopy 6de0aad79c Edition de fichier distant en place, et journalisation de session
Edition SFTP facon WinSCP : clic droit sur un fichier distant > "Editer".
Le fichier est telecharge dans un temporaire, ouvert avec l'application
associee du systeme, et renvoye automatiquement a chaque enregistrement.
Beaucoup d'editeurs enregistrent en remplacant le fichier plutot qu'en le
reecrivant : le chemin surveille disparait alors du QFileSystemWatcher, il est
donc reinscrit apres chaque renvoi pour que les enregistrements suivants
fonctionnent aussi.

Journalisation : un bouton dans la barre de l'onglet enregistre toute la
session dans un fichier. Les sequences d'echappement ANSI sont retirees pour
que le journal reste lisible dans un editeur de texte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:35:39 +02:00

733 lines
28 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,
)
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."""
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: paramiko.SFTPClient, jobs: list[TransferJob]):
super().__init__()
self.sftp = sftp
self.jobs = jobs
self._cancel = False
self._done = 0
self._errors: list[str] = []
def cancel(self) -> None:
self._cancel = True
def run(self) -> None:
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)
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):
def __init__(self, sftp: paramiko.SFTPClient, parent=None):
super().__init__("🌐 Distant", "remote", parent)
self.sftp = sftp
# 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)
try:
home = self.sftp.normalize(".")
except Exception:
home = "/"
self.navigate(home)
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 "/"
def navigate(self, path: str) -> None:
try:
attrs = self.sftp.listdir_attr(path)
except IOError as exc:
QMessageBox.warning(self, "Erreur SFTP", f"{path} : {exc}")
return
self.current_path = path
self.path_edit.setText(path)
self._populate(attrs)
self.path_changed.emit(path)
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
try:
self.sftp.mkdir(self.join(self.current_path, name))
self.refresh()
except IOError 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:
self.sftp.rename(self.join(self.current_path, old_name), self.join(self.current_path, new_name))
self.refresh()
except IOError 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) sur le serveur ?",
) != QMessageBox.Yes:
return
for name, is_dir in entries:
path = self.join(self.current_path, name)
try:
if is_dir:
self._rmdir_recursive(path)
else:
self.sftp.remove(path)
except IOError as exc:
QMessageBox.critical(self, "Erreur", f"{name} : {exc}")
self.refresh()
def _rmdir_recursive(self, path: str) -> None:
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
try:
self.sftp.get(remote_path, str(local_path))
except (IOError, OSError) as exc:
QMessageBox.critical(self, "Erreur", f"Telechargement impossible : {exc}")
return
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_edited_file_changed(self, local_path: str) -> None:
remote_path = self._edited.get(local_path)
if not remote_path:
return
try:
self.sftp.put(local_path, remote_path)
except (IOError, OSError) as exc:
self.status.setText(f"Echec du renvoi : {exc}")
return
# 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()
# apres refresh() : sinon le message serait ecrase par le compteur d'elements
self.status.setText(f"↑ {posixpath.basename(remote_path)} renvoye sur le serveur")
def change_permissions(self) -> None:
entries = self.selected_entries()
if not entries:
return
name = entries[0][0]
path = self.join(self.current_path, name)
try:
current = stat.S_IMODE(self.sftp.stat(path).st_mode)
except IOError as exc:
QMessageBox.critical(self, "Erreur", str(exc))
return
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
errors = []
for entry_name, _ in entries:
try:
self.sftp.chmod(self.join(self.current_path, entry_name), mode)
except IOError as exc:
errors.append(f"{entry_name} : {exc}")
if errors:
QMessageBox.critical(self, "Erreur", "\n".join(errors))
self.refresh()
# ----------------------------------------------------------------------------
# Conteneur double panneau
# ----------------------------------------------------------------------------
class SFTPPanel(QWidget):
def __init__(self, sftp: paramiko.SFTPClient, parent=None):
super().__init__(parent)
self.sftp = sftp
self._worker: TransferWorker | None = None
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 = RemoteFilePanel(sftp)
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 _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.sftp, jobs)
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()