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>
This commit is contained in:
snoopy
2026-09-04 11:35:39 +02:00
parent f537868da3
commit 6de0aad79c
2 changed files with 917 additions and 797 deletions

View File

@@ -4,13 +4,15 @@ from __future__ import annotations
import copy
import re import re
import uuid import uuid
from datetime import datetime from datetime import datetime
QStatusBar, QFrame, QApplication, QLineEdit, from pathlib import Path
@@ -36,6 +38,14 @@ SSH_VALUE_OPTIONS = {
from app.ssh_connection import SSHConnection from app.ssh_connection import SSHConnection
from app.local_terminal import LocalTerminalConnection, SHELL_POWERSHELL, SHELL_CMD, SHELL_LABELS
from app.terminal_widget import TerminalWidget
from app.sftp_panel import SFTPPanel
from app.dialogs.session_dialog import SessionDialog
from app.dialogs.key_dialog import KeyManagerDialog from app.dialogs.key_dialog import KeyManagerDialog
from app.dialogs.known_hosts_dialog import KnownHostsDialog from app.dialogs.known_hosts_dialog import KnownHostsDialog
@@ -102,6 +112,13 @@ class BaseTerminalTab(QWidget):
if token == "-p" and value.isdigit(): if token == "-p" and value.isdigit():
port = int(value) port = int(value)
if token == "-l" and value:
user = value
index += 2
continue continue
if token.startswith("-p") and token[2:].isdigit(): # forme collee : -p2222 if token.startswith("-p") and token[2:].isdigit(): # forme collee : -p2222
@@ -135,10 +152,53 @@ class BaseTerminalTab(QWidget):
def detect_ssh_target(text: str) -> dict | None: def detect_ssh_target(text: str) -> dict | None:
"""Renvoie la derniere cible ssh trouvee dans le texte d'un terminal.""" """Renvoie la derniere cible ssh trouvee dans le texte d'un terminal."""
result = None
for match in SSH_COMMAND_RE.finditer(text):
parsed = _parse_ssh_args(match.group("args"))
if parsed:
result = parsed
return result
class BaseTerminalTab(QWidget):
"""Base commune a un onglet terminal : barre de statut + terminal, autour d'une
connexion (SSH ou locale) qui expose la meme interface de signaux
(connected / data_received / disconnected / error / send / resize)."""
def __init__(self, initial_status: str, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.bar = QHBoxLayout()
self.bar.setContentsMargins(10, 6, 10, 6)
self.status_label = QLabel(initial_status) self.status_label = QLabel(initial_status)
self.status_label.setObjectName("statusLabel") self.status_label.setObjectName("statusLabel")
self.bar.addWidget(self.status_label, 1) self.bar.addWidget(self.status_label, 1)
@@ -195,6 +255,7 @@ class BaseTerminalTab(QWidget):
# barre de recherche, masquee tant qu'on n'a pas fait Ctrl+F # barre de recherche, masquee tant qu'on n'a pas fait Ctrl+F
self.search_bar = QFrame()
self.search_bar.setObjectName("tabBar") self.search_bar.setObjectName("tabBar")

View File

@@ -5,12 +5,13 @@ from __future__ import annotations
import json import json
import os import os
import posixpath import posixpath
import tempfile
import stat import stat
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import paramiko import paramiko
from PySide6.QtCore import Qt, QThread, Signal, QMimeData, QPoint from PySide6.QtCore import Qt, QThread, Signal, QMimeData, QPoint, QFileSystemWatcher
from PySide6.QtGui import QDrag, QKeySequence, QShortcut from PySide6.QtGui import QDrag, QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QToolButton, QTreeWidget, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QToolButton, QTreeWidget,
@@ -324,9 +325,11 @@ class FilePanel(QWidget):
act_rename.setEnabled(has_selection) act_rename.setEnabled(has_selection)
act_delete = menu.addAction("Supprimer (Suppr)") act_delete = menu.addAction("Supprimer (Suppr)")
act_delete.setEnabled(has_selection) act_delete.setEnabled(has_selection)
act_perms = None act_perms = act_edit = None
if self.side == "remote": if self.side == "remote":
menu.addSeparator() menu.addSeparator()
act_edit = menu.addAction("Editer (renvoi automatique a l'enregistrement)")
act_edit.setEnabled(has_selection)
act_perms = menu.addAction("Permissions...") act_perms = menu.addAction("Permissions...")
act_perms.setEnabled(has_selection) act_perms.setEnabled(has_selection)
menu.addSeparator() menu.addSeparator()
@@ -340,6 +343,8 @@ class FilePanel(QWidget):
self.rename_selected() self.rename_selected()
elif action == act_delete: elif action == act_delete:
self.delete_selected() 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: elif act_perms is not None and action == act_perms:
self.change_permissions() self.change_permissions()
elif action == act_mkdir: elif action == act_mkdir:
@@ -361,6 +366,7 @@ class FilePanel(QWidget):
def rename_selected(self) -> None: ... def rename_selected(self) -> None: ...
def delete_selected(self) -> None: ... def delete_selected(self) -> None: ...
def change_permissions(self) -> None: ... def change_permissions(self) -> None: ...
def edit_selected(self) -> None: ...
class LocalFilePanel(FilePanel): class LocalFilePanel(FilePanel):
@@ -453,6 +459,10 @@ class RemoteFilePanel(FilePanel):
def __init__(self, sftp: paramiko.SFTPClient, parent=None): def __init__(self, sftp: paramiko.SFTPClient, parent=None):
super().__init__("🌐 Distant", "remote", parent) super().__init__("🌐 Distant", "remote", parent)
self.sftp = sftp 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: try:
home = self.sftp.normalize(".") home = self.sftp.normalize(".")
except Exception: except Exception:
@@ -546,6 +556,55 @@ class RemoteFilePanel(FilePanel):
self.sftp.remove(sub) self.sftp.remove(sub)
self.sftp.rmdir(path) 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: def change_permissions(self) -> None:
entries = self.selected_entries() entries = self.selected_entries()
if not entries: if not entries: