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:
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user