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>
873 lines
34 KiB
Python
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()
|