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>
This commit is contained in:
Florian
2026-09-05 01:42:46 +02:00
parent 47005b1e26
commit fd848c2025
4 changed files with 326 additions and 108 deletions

View File

@@ -70,6 +70,19 @@ class FakeSFTP:
app = QApplication([])
def wait_idle(panel_side, timeout=5.0):
"""Pompe la boucle d'evenements Qt jusqu'a ce que toutes les operations SFTP
soumises par ce panneau (navigate/refresh/etc, desormais asynchrones) soient
revenues."""
deadline = time.time() + timeout
app.processEvents()
while panel_side._callbacks and time.time() < deadline:
app.processEvents()
time.sleep(0.01)
app.processEvents()
remote_root = Path(tempfile.mkdtemp())
local_root = Path(tempfile.mkdtemp())
@@ -85,6 +98,7 @@ local_root = Path(tempfile.mkdtemp())
panel = SFTPPanel(FakeSFTP(remote_root))
panel.local.navigate(str(local_root))
panel.resize(1000, 600)
wait_idle(panel.remote) # laisse le temps a la navigation initiale (asynchrone) d'arriver
print("=== 1. Listing distant avec colonnes ===")
tree = panel.remote.tree
@@ -135,11 +149,13 @@ assert got.exists()
print()
print("=== 5. Renommage distant ===")
panel.remote.navigate("/")
wait_idle(panel.remote)
item = next(panel.remote.tree.topLevelItem(i) for i in range(panel.remote.tree.topLevelItemCount())
if panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE) == "config.yml")
item.setSelected(True)
panel.remote.sftp.rename("/config.yml", "/config_renomme.yml")
panel.remote.refresh()
wait_idle(panel.remote)
names = [panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE)
for i in range(panel.remote.tree.topLevelItemCount())]
print(" entrees apres renommage:", [n for n in names if n])
@@ -155,6 +171,7 @@ print()
print("=== 7. Suppression recursive distante ===")
panel.remote._rmdir_recursive("/dossier_local")
panel.remote.refresh()
wait_idle(panel.remote)
print(" dossier supprime du serveur:", not (remote_root / "dossier_local").exists())
assert not (remote_root / "dossier_local").exists()
@@ -165,5 +182,7 @@ has_parent = any(panel.remote.tree.topLevelItem(i).data(0, PARENT_ROLE)
print(" ligne '..' presente:", has_parent)
assert has_parent
panel.shutdown()
print()
print("TOUS LES TESTS SFTP OK")