Copier-coller dans le terminal, et suite de tests versionnee
Le terminal n'avait aucun copier-coller : impossible de selectionner du texte a la souris ni de coller une commande. C'est la fonction la plus basique d'un terminal et de loin le manque le plus penible a l'usage. - selection a la souris avec surlignage, suivant le theme actif - double-clic : selectionne le mot sous le curseur - Ctrl+Shift+C / Ctrl+Shift+V, Maj+Inser, clic molette, menu au clic droit - Ctrl+C reste l'interruption : les raccourcis du presse-papiers sont traites avant la conversion Ctrl+lettre, sinon Ctrl+Shift+C serait parti comme un Ctrl+C vers le shell - au collage, les fins de ligne sont normalisees en retour chariot ; un \r\n enverrait une ligne vide supplementaire au shell La suite de tests vivait dans un dossier temporaire et aurait ete perdue : elle rejoint le depot dans tests/ avec un lanceur. Elle couvre le presse-papiers, la detection d'hote, la logique SFTP sur serveur simule et la construction de l'application. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
16
README.md
16
README.md
@@ -4,7 +4,10 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com
|
|||||||
|
|
||||||
## Fonctionnalites
|
## Fonctionnalites
|
||||||
|
|
||||||
- Sessions SSH multiples dans des onglets, avec terminal ANSI colore (theme "bleu nuit")
|
- Sessions SSH multiples dans des onglets, avec terminal ANSI colore et trois themes
|
||||||
|
- Copier-coller dans le terminal : selection a la souris, double-clic pour selectionner un mot,
|
||||||
|
`Ctrl+Shift+C` / `Ctrl+Shift+V`, `Maj+Inser`, clic molette, et menu au clic droit.
|
||||||
|
`Ctrl+C` reste l'interruption, comme dans tout terminal
|
||||||
- Terminaux locaux Windows (PowerShell / Invite de commandes) accessibles en un clic, **sans avoir besoin d'une session enregistree** — pratique pour un usage ponctuel, comme le terminal Windows natif
|
- Terminaux locaux Windows (PowerShell / Invite de commandes) accessibles en un clic, **sans avoir besoin d'une session enregistree** — pratique pour un usage ponctuel, comme le terminal Windows natif
|
||||||
- Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic
|
- Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic
|
||||||
- Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee)
|
- Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee)
|
||||||
@@ -121,6 +124,17 @@ data/
|
|||||||
known_hosts empreintes d'hotes approuvees (non versionne)
|
known_hosts empreintes d'hotes approuvees (non versionne)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests\run_all.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests construisent de vraies fenetres Qt sans les afficher (`QT_QPA_PLATFORM=offscreen`) et
|
||||||
|
couvrent : le copier-coller et les raccourcis du terminal, la detection de l'hote dans une commande
|
||||||
|
`ssh` tapee a la main, la logique SFTP (transferts, renommage, tri, suppression recursive) avec un
|
||||||
|
serveur simule, et la construction complete de l'application.
|
||||||
|
|
||||||
## Limites connues / pistes d'amelioration
|
## Limites connues / pistes d'amelioration
|
||||||
|
|
||||||
- L'emulation de terminal couvre les usages courants (bash, vim, htop...) mais n'est pas un xterm parfaitement complet.
|
- L'emulation de terminal couvre les usages courants (bash, vim, htop...) mais n'est pas un xterm parfaitement complet.
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pyte
|
import pyte
|
||||||
from PySide6.QtCore import QRect, Qt, Signal
|
from PySide6.QtCore import QRect, Qt, Signal
|
||||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent
|
from PySide6.QtGui import (
|
||||||
from PySide6.QtWidgets import QWidget
|
QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent, QKeySequence,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import QWidget, QMenu, QApplication
|
||||||
|
|
||||||
from app import theme as app_theme
|
from app import theme as app_theme
|
||||||
|
|
||||||
@@ -74,6 +76,11 @@ class TerminalWidget(QWidget):
|
|||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
self._prev_cursor_y = 0
|
self._prev_cursor_y = 0
|
||||||
|
|
||||||
|
# selection a la souris : coordonnees (colonne, ligne) dans la grille
|
||||||
|
self._sel_anchor: tuple[int, int] | None = None
|
||||||
|
self._sel_focus: tuple[int, int] | None = None
|
||||||
|
self._selecting = False
|
||||||
|
|
||||||
self._load_palette()
|
self._load_palette()
|
||||||
self.setMinimumSize(200, 100)
|
self.setMinimumSize(200, 100)
|
||||||
|
|
||||||
@@ -90,12 +97,81 @@ class TerminalWidget(QWidget):
|
|||||||
name: QColor(value) for name, value in palette["ansi"].items()
|
name: QColor(value) for name, value in palette["ansi"].items()
|
||||||
}
|
}
|
||||||
self._bold_fg = QColor(palette["ansi"]["brightwhite"])
|
self._bold_fg = QColor(palette["ansi"]["brightwhite"])
|
||||||
|
self.selection_color = QColor(*palette.get("selection", (79, 140, 255, 90)))
|
||||||
|
|
||||||
def refresh_theme(self) -> None:
|
def refresh_theme(self) -> None:
|
||||||
"""Recharge les couleurs apres un changement de theme, et redessine."""
|
"""Recharge les couleurs apres un changement de theme, et redessine."""
|
||||||
self._load_palette()
|
self._load_palette()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
# ---------- selection a la souris ----------
|
||||||
|
def _cell_at(self, pos) -> tuple[int, int]:
|
||||||
|
col = max(0, min(self.cols - 1, pos.x() // self.char_w))
|
||||||
|
row = max(0, min(self.rows - 1, pos.y() // self.char_h))
|
||||||
|
return col, row
|
||||||
|
|
||||||
|
def _ordered_selection(self) -> tuple[tuple[int, int], tuple[int, int]] | None:
|
||||||
|
"""Renvoie (debut, fin) remis dans l'ordre de lecture."""
|
||||||
|
if self._sel_anchor is None or self._sel_focus is None:
|
||||||
|
return None
|
||||||
|
a, b = self._sel_anchor, self._sel_focus
|
||||||
|
if (a[1], a[0]) <= (b[1], b[0]):
|
||||||
|
return a, b
|
||||||
|
return b, a
|
||||||
|
|
||||||
|
def _selected_columns(self, row: int) -> tuple[int, int] | None:
|
||||||
|
"""Plage [debut, fin[ de colonnes selectionnees sur cette ligne."""
|
||||||
|
ordered = self._ordered_selection()
|
||||||
|
if not ordered:
|
||||||
|
return None
|
||||||
|
(sx, sy), (ex, ey) = ordered
|
||||||
|
if row < sy or row > ey:
|
||||||
|
return None
|
||||||
|
start = sx if row == sy else 0
|
||||||
|
end = (ex + 1) if row == ey else self.cols
|
||||||
|
return (start, end) if end > start else None
|
||||||
|
|
||||||
|
def has_selection(self) -> bool:
|
||||||
|
return self._ordered_selection() is not None
|
||||||
|
|
||||||
|
def selected_text(self) -> str:
|
||||||
|
ordered = self._ordered_selection()
|
||||||
|
if not ordered:
|
||||||
|
return ""
|
||||||
|
lines = []
|
||||||
|
for row in range(ordered[0][1], ordered[1][1] + 1):
|
||||||
|
span = self._selected_columns(row)
|
||||||
|
if not span:
|
||||||
|
continue
|
||||||
|
line = self.screen.buffer[row]
|
||||||
|
# rstrip : le tampon est complete par des espaces jusqu'au bord de l'ecran
|
||||||
|
lines.append("".join(line[col].data for col in range(*span)).rstrip())
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def clear_selection(self) -> None:
|
||||||
|
if self._sel_anchor is not None:
|
||||||
|
self._sel_anchor = self._sel_focus = None
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def select_all(self) -> None:
|
||||||
|
self._sel_anchor = (0, 0)
|
||||||
|
self._sel_focus = (self.cols - 1, self.rows - 1)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ---------- presse-papiers ----------
|
||||||
|
def copy_selection(self) -> None:
|
||||||
|
text = self.selected_text()
|
||||||
|
if text:
|
||||||
|
QApplication.clipboard().setText(text)
|
||||||
|
|
||||||
|
def paste_clipboard(self) -> None:
|
||||||
|
text = QApplication.clipboard().text()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
# un terminal attend un retour chariot ; \r\n enverrait une ligne vide de plus
|
||||||
|
payload = text.replace("\r\n", "\r").replace("\n", "\r")
|
||||||
|
self.data_to_send.emit(payload.encode("utf-8", errors="ignore"))
|
||||||
|
|
||||||
def buffer_text(self) -> str:
|
def buffer_text(self) -> str:
|
||||||
"""Texte visible + historique proche, pour retrouver ce qui a ete tape
|
"""Texte visible + historique proche, pour retrouver ce qui a ete tape
|
||||||
(utilise pour deduire l'hote d'une commande ssh saisie a la main).
|
(utilise pour deduire l'hote d'une commande ssh saisie a la main).
|
||||||
@@ -189,6 +265,17 @@ class TerminalWidget(QWidget):
|
|||||||
painter.setPen(fg)
|
painter.setPen(fg)
|
||||||
painter.drawText(px, py + self.char_h - 4, run_text)
|
painter.drawText(px, py + self.char_h - 4, run_text)
|
||||||
|
|
||||||
|
# surlignage de la selection, par-dessus le texte
|
||||||
|
for y in range(y0, y1 + 1):
|
||||||
|
span = self._selected_columns(y)
|
||||||
|
if span:
|
||||||
|
start, end = span
|
||||||
|
painter.fillRect(
|
||||||
|
start * self.char_w, y * self.char_h,
|
||||||
|
(end - start) * self.char_w, self.char_h,
|
||||||
|
self.selection_color,
|
||||||
|
)
|
||||||
|
|
||||||
# curseur (bloc plein), non affiche pendant un scroll dans l'historique
|
# curseur (bloc plein), non affiche pendant un scroll dans l'historique
|
||||||
if self.scroll_offset == 0 and not self.screen.cursor.hidden and y0 <= cursor.y <= y1:
|
if self.scroll_offset == 0 and not self.screen.cursor.hidden and y0 <= cursor.y <= y1:
|
||||||
cx, cy = cursor.x, cursor.y
|
cx, cy = cursor.x, cursor.y
|
||||||
@@ -231,6 +318,20 @@ class TerminalWidget(QWidget):
|
|||||||
key = event.key()
|
key = event.key()
|
||||||
modifiers = event.modifiers()
|
modifiers = event.modifiers()
|
||||||
|
|
||||||
|
# Copier/coller AVANT la gestion de Ctrl+lettre : sinon Ctrl+Shift+C partirait
|
||||||
|
# comme un Ctrl+C (interruption) au lieu de copier. Ctrl+C seul reste l'interruption,
|
||||||
|
# comme dans tous les terminaux.
|
||||||
|
if (modifiers & Qt.ControlModifier) and (modifiers & Qt.ShiftModifier):
|
||||||
|
if key == Qt.Key_C:
|
||||||
|
self.copy_selection()
|
||||||
|
return
|
||||||
|
if key == Qt.Key_V:
|
||||||
|
self.paste_clipboard()
|
||||||
|
return
|
||||||
|
if key == Qt.Key_Insert and (modifiers & Qt.ShiftModifier):
|
||||||
|
self.paste_clipboard()
|
||||||
|
return
|
||||||
|
|
||||||
# Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce :
|
# Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce :
|
||||||
# sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper
|
# sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper
|
||||||
# @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble.
|
# @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble.
|
||||||
@@ -263,4 +364,59 @@ class TerminalWidget(QWidget):
|
|||||||
|
|
||||||
def mousePressEvent(self, event) -> None:
|
def mousePressEvent(self, event) -> None:
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
|
if event.button() == Qt.LeftButton:
|
||||||
|
self._sel_anchor = self._sel_focus = self._cell_at(event.position().toPoint())
|
||||||
|
self._selecting = True
|
||||||
|
self.update()
|
||||||
|
elif event.button() == Qt.MiddleButton:
|
||||||
|
self.paste_clipboard() # habitude Unix : clic molette = coller
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event) -> None:
|
||||||
|
if self._selecting:
|
||||||
|
self._sel_focus = self._cell_at(event.position().toPoint())
|
||||||
|
self.update()
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event) -> None:
|
||||||
|
if event.button() == Qt.LeftButton:
|
||||||
|
self._selecting = False
|
||||||
|
# un simple clic (sans deplacement) annule la selection
|
||||||
|
if self._sel_anchor == self._sel_focus:
|
||||||
|
self.clear_selection()
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event) -> None:
|
||||||
|
"""Double-clic : selectionne le mot sous le curseur."""
|
||||||
|
col, row = self._cell_at(event.position().toPoint())
|
||||||
|
line = self.screen.buffer[row]
|
||||||
|
separators = " \t\"'`()[]{}<>|;:,"
|
||||||
|
if line[col].data in separators:
|
||||||
|
return
|
||||||
|
start = col
|
||||||
|
while start > 0 and line[start - 1].data not in separators:
|
||||||
|
start -= 1
|
||||||
|
end = col
|
||||||
|
while end < self.cols - 1 and line[end + 1].data not in separators:
|
||||||
|
end += 1
|
||||||
|
self._sel_anchor, self._sel_focus = (start, row), (end, row)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event) -> None:
|
||||||
|
menu = QMenu(self)
|
||||||
|
copy_action = menu.addAction("Copier")
|
||||||
|
copy_action.setShortcut(QKeySequence("Ctrl+Shift+C"))
|
||||||
|
copy_action.setEnabled(self.has_selection())
|
||||||
|
paste_action = menu.addAction("Coller")
|
||||||
|
paste_action.setShortcut(QKeySequence("Ctrl+Shift+V"))
|
||||||
|
paste_action.setEnabled(bool(QApplication.clipboard().text()))
|
||||||
|
menu.addSeparator()
|
||||||
|
select_all_action = menu.addAction("Tout selectionner")
|
||||||
|
|
||||||
|
action = menu.exec(event.globalPos())
|
||||||
|
if action == copy_action:
|
||||||
|
self.copy_selection()
|
||||||
|
elif action == paste_action:
|
||||||
|
self.paste_clipboard()
|
||||||
|
elif action == select_all_action:
|
||||||
|
self.select_all()
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ UI_PALETTES: dict[str, dict[str, str]] = {
|
|||||||
TERMINAL_PALETTES: dict[str, dict] = {
|
TERMINAL_PALETTES: dict[str, dict] = {
|
||||||
THEME_LIGHT: {
|
THEME_LIGHT: {
|
||||||
"fg": "#1c2430", "bg": "#ffffff", "cursor": (47, 111, 237, 110),
|
"fg": "#1c2430", "bg": "#ffffff", "cursor": (47, 111, 237, 110),
|
||||||
|
"selection": (47, 111, 237, 70),
|
||||||
"ansi": {
|
"ansi": {
|
||||||
"black": "#1c2430", "red": "#c0392b", "green": "#2e7d32", "brown": "#a06800",
|
"black": "#1c2430", "red": "#c0392b", "green": "#2e7d32", "brown": "#a06800",
|
||||||
"blue": "#1f5ed6", "magenta": "#8e24aa", "cyan": "#00838f", "white": "#5c6875",
|
"blue": "#1f5ed6", "magenta": "#8e24aa", "cyan": "#00838f", "white": "#5c6875",
|
||||||
@@ -69,6 +70,7 @@ TERMINAL_PALETTES: dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
THEME_MIDNIGHT: {
|
THEME_MIDNIGHT: {
|
||||||
"fg": "#e6ecf6", "bg": "#0b1220", "cursor": (79, 140, 255, 140),
|
"fg": "#e6ecf6", "bg": "#0b1220", "cursor": (79, 140, 255, 140),
|
||||||
|
"selection": (79, 140, 255, 90),
|
||||||
"ansi": {
|
"ansi": {
|
||||||
"black": "#0b1220", "red": "#ff6b81", "green": "#8ce99a", "brown": "#f2c94c",
|
"black": "#0b1220", "red": "#ff6b81", "green": "#8ce99a", "brown": "#f2c94c",
|
||||||
"blue": "#4f8cff", "magenta": "#c792ea", "cyan": "#63d4e0", "white": "#dbe4f5",
|
"blue": "#4f8cff", "magenta": "#c792ea", "cyan": "#63d4e0", "white": "#dbe4f5",
|
||||||
@@ -79,6 +81,7 @@ TERMINAL_PALETTES: dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
THEME_BLACK: {
|
THEME_BLACK: {
|
||||||
"fg": "#e8e8e8", "bg": "#000000", "cursor": (74, 158, 255, 140),
|
"fg": "#e8e8e8", "bg": "#000000", "cursor": (74, 158, 255, 140),
|
||||||
|
"selection": (74, 158, 255, 90),
|
||||||
"ansi": {
|
"ansi": {
|
||||||
"black": "#000000", "red": "#e05561", "green": "#8cc265", "brown": "#d18f52",
|
"black": "#000000", "red": "#e05561", "green": "#8cc265", "brown": "#d18f52",
|
||||||
"blue": "#4aa5f0", "magenta": "#c162de", "cyan": "#42b3c2", "white": "#d0d0d0",
|
"blue": "#4aa5f0", "magenta": "#c162de", "cyan": "#42b3c2", "white": "#d0d0d0",
|
||||||
|
|||||||
29
tests/run_all.bat
Normal file
29
tests/run_all.bat
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0\.."
|
||||||
|
|
||||||
|
if not exist venv (
|
||||||
|
echo Environnement virtuel introuvable. Lancez d'abord setup.bat
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM QT_QPA_PLATFORM=offscreen : les tests construisent de vraies fenetres Qt,
|
||||||
|
REM mais sans les afficher (utile en console comme en integration continue).
|
||||||
|
set QT_QPA_PLATFORM=offscreen
|
||||||
|
set PYTHONIOENCODING=utf-8
|
||||||
|
|
||||||
|
set FAILED=0
|
||||||
|
for %%T in (test_detect test_clipboard test_sftp test_full) do (
|
||||||
|
echo.
|
||||||
|
echo ================== %%T ==================
|
||||||
|
venv\Scripts\python.exe tests\%%T.py || set FAILED=1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if "%FAILED%"=="1" (
|
||||||
|
echo Au moins un test a echoue.
|
||||||
|
) else (
|
||||||
|
echo Tous les tests sont passes.
|
||||||
|
)
|
||||||
|
pause
|
||||||
92
tests/test_clipboard.py
Normal file
92
tests/test_clipboard.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Copier/coller du terminal : selection, presse-papiers, raccourcis."""
|
||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from PySide6.QtGui import QKeyEvent
|
||||||
|
from PySide6.QtCore import QEvent, Qt, QPoint
|
||||||
|
|
||||||
|
from app.terminal_widget import TerminalWidget
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
t = TerminalWidget()
|
||||||
|
t.resize(800, 400)
|
||||||
|
t.show()
|
||||||
|
t.feed(b"root@srv:~# cat /etc/hostname\r\nserveur-test-01\r\nroot@srv:~# ")
|
||||||
|
|
||||||
|
print("=== 1. Selection d'une plage ===")
|
||||||
|
# selectionne "serveur-test-01" sur la ligne 1
|
||||||
|
t._sel_anchor, t._sel_focus = (0, 1), (14, 1)
|
||||||
|
print(" texte selectionne:", repr(t.selected_text()))
|
||||||
|
assert t.selected_text() == "serveur-test-01", t.selected_text()
|
||||||
|
assert t.has_selection()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 2. Copie vers le presse-papiers ===")
|
||||||
|
t.copy_selection()
|
||||||
|
print(" presse-papiers:", repr(QApplication.clipboard().text()))
|
||||||
|
assert QApplication.clipboard().text() == "serveur-test-01"
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 3. Selection sur plusieurs lignes ===")
|
||||||
|
t._sel_anchor, t._sel_focus = (0, 0), (14, 1)
|
||||||
|
lignes = t.selected_text().split("\n")
|
||||||
|
print(" lignes:", lignes)
|
||||||
|
assert lignes[0].startswith("root@srv"), lignes
|
||||||
|
assert lignes[1] == "serveur-test-01", lignes
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 4. Collage (le presse-papiers part vers le shell) ===")
|
||||||
|
envoye = []
|
||||||
|
t.data_to_send.connect(lambda d: envoye.append(d))
|
||||||
|
QApplication.clipboard().setText("ssh root@192.168.10.109\n")
|
||||||
|
t.paste_clipboard()
|
||||||
|
print(" octets envoyes:", envoye)
|
||||||
|
# le \n doit devenir \r, sinon le shell recoit une ligne vide en plus
|
||||||
|
assert envoye == [b"ssh root@192.168.10.109\r"], envoye
|
||||||
|
envoye.clear()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 5. Ctrl+C doit rester l'interruption, Ctrl+Shift+C doit copier ===")
|
||||||
|
t._sel_anchor, t._sel_focus = (0, 1), (14, 1)
|
||||||
|
QApplication.clipboard().setText("")
|
||||||
|
|
||||||
|
t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_C, Qt.ControlModifier, ""))
|
||||||
|
print(" Ctrl+C -> envoye:", envoye, "| presse-papiers:", repr(QApplication.clipboard().text()))
|
||||||
|
assert envoye == [bytes([0x03])], envoye
|
||||||
|
assert QApplication.clipboard().text() == "", "Ctrl+C ne doit pas copier"
|
||||||
|
envoye.clear()
|
||||||
|
|
||||||
|
t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_C, Qt.ControlModifier | Qt.ShiftModifier, ""))
|
||||||
|
print(" Ctrl+Shift+C -> envoye:", envoye, "| presse-papiers:", repr(QApplication.clipboard().text()))
|
||||||
|
assert envoye == [], "Ctrl+Shift+C ne doit rien envoyer au shell"
|
||||||
|
assert QApplication.clipboard().text() == "serveur-test-01"
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 6. Ctrl+Shift+V et Maj+Inser collent ===")
|
||||||
|
QApplication.clipboard().setText("echo test")
|
||||||
|
envoye.clear()
|
||||||
|
t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_V, Qt.ControlModifier | Qt.ShiftModifier, ""))
|
||||||
|
t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_Insert, Qt.ShiftModifier, ""))
|
||||||
|
print(" envoye:", envoye)
|
||||||
|
assert envoye == [b"echo test", b"echo test"], envoye
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 7. Double-clic : selection du mot ===")
|
||||||
|
t.clear_selection()
|
||||||
|
# ligne 1 = "serveur-test-01" ; les tirets ne sont pas des separateurs
|
||||||
|
class FakeEvt:
|
||||||
|
def position(self): return type("P", (), {"toPoint": lambda s: QPoint(5 * t.char_w, 1 * t.char_h)})()
|
||||||
|
t.mouseDoubleClickEvent(FakeEvt())
|
||||||
|
print(" mot selectionne:", repr(t.selected_text()))
|
||||||
|
assert t.selected_text() == "serveur-test-01", t.selected_text()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 8. Tout selectionner / effacer la selection ===")
|
||||||
|
t.select_all()
|
||||||
|
assert t.has_selection()
|
||||||
|
t.clear_selection()
|
||||||
|
assert not t.has_selection()
|
||||||
|
print(" OK")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("COPIER/COLLER : TOUS LES TESTS OK")
|
||||||
34
tests/test_detect.py
Normal file
34
tests/test_detect.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
from app.main_window import detect_ssh_target
|
||||||
|
|
||||||
|
cas = [
|
||||||
|
(r"C:\Users\Florian>ssh root@192.168.10.109", {"user": "root", "host": "192.168.10.109", "port": 22}),
|
||||||
|
(r"PS C:\> ssh admin@srv-test.local", {"user": "admin", "host": "srv-test.local", "port": 22}),
|
||||||
|
(r"> ssh -p 2222 root@10.0.0.5", {"user": "root", "host": "10.0.0.5", "port": 2222}),
|
||||||
|
(r"> ssh root@10.0.0.5 -p 2222", {"user": "root", "host": "10.0.0.5", "port": 2222}),
|
||||||
|
(r"> ssh monserveur", {"user": "", "host": "monserveur", "port": 22}),
|
||||||
|
("rien a voir ici", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
for texte, attendu in cas:
|
||||||
|
got = detect_ssh_target(texte)
|
||||||
|
statut = "OK " if got == attendu else "ECHEC"
|
||||||
|
if got != attendu:
|
||||||
|
ok = False
|
||||||
|
print(f"{statut} | {texte[:38]:<40} -> {got}")
|
||||||
|
|
||||||
|
multi = "\n".join([
|
||||||
|
r"C:\Users>ssh root@192.168.1.1",
|
||||||
|
"Connection closed.",
|
||||||
|
r"C:\Users>ssh deploy@192.168.10.109",
|
||||||
|
"Last login: Wed Sep 2 20:39:31 2026",
|
||||||
|
])
|
||||||
|
got = detect_ssh_target(multi)
|
||||||
|
print()
|
||||||
|
print("plusieurs commandes -> derniere retenue:", got)
|
||||||
|
assert got and got["host"] == "192.168.10.109" and got["user"] == "deploy", got
|
||||||
|
assert ok, "au moins un cas a echoue"
|
||||||
|
print()
|
||||||
|
print("DETECTION AUTOMATIQUE DE L'HOTE : OK")
|
||||||
19
tests/test_full.py
Normal file
19
tests/test_full.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from app.main_window import MainWindow
|
||||||
|
from app.theme import apply_theme, build_app_icon
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
w = MainWindow()
|
||||||
|
w.setWindowIcon(build_app_icon())
|
||||||
|
w.show()
|
||||||
|
print("OK: application complete construite sans erreur")
|
||||||
|
|
||||||
|
# nettoyage explicite : le terminal local auto-ouvert au demarrage laisse un
|
||||||
|
# vrai sous-processus + threads vivants, qu'il faut fermer avant de quitter
|
||||||
|
for i in range(w.tabs.count()):
|
||||||
|
tab = w.tabs.widget(i)
|
||||||
|
tab.close_connection()
|
||||||
|
print("OK: connexions fermees proprement")
|
||||||
169
tests/test_sftp.py
Normal file
169
tests/test_sftp.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Test de la logique SFTP avec un client SFTP simule (adosse a un dossier temporaire)."""
|
||||||
|
import sys, pathlib
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import shutil
|
||||||
|
import stat as statmod
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from app.sftp_panel import SFTPPanel, TransferJob, NAME_ROLE, ISDIR_ROLE, PARENT_ROLE
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAttr:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
st = path.stat()
|
||||||
|
self.filename = path.name
|
||||||
|
self.st_mode = st.st_mode
|
||||||
|
self.st_size = st.st_size
|
||||||
|
self.st_mtime = st.st_mtime
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSFTP:
|
||||||
|
"""Implemente juste ce que le panneau utilise, adosse a un vrai dossier local."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
def _real(self, path: str) -> Path:
|
||||||
|
return self.root / path.lstrip("/")
|
||||||
|
|
||||||
|
def normalize(self, path):
|
||||||
|
return "/"
|
||||||
|
|
||||||
|
def listdir_attr(self, path):
|
||||||
|
return [FakeAttr(p) for p in sorted(self._real(path).iterdir())]
|
||||||
|
|
||||||
|
def stat(self, path):
|
||||||
|
return FakeAttr(self._real(path))
|
||||||
|
|
||||||
|
def mkdir(self, path):
|
||||||
|
self._real(path).mkdir()
|
||||||
|
|
||||||
|
def rename(self, old, new):
|
||||||
|
self._real(old).rename(self._real(new))
|
||||||
|
|
||||||
|
def remove(self, path):
|
||||||
|
self._real(path).unlink()
|
||||||
|
|
||||||
|
def rmdir(self, path):
|
||||||
|
self._real(path).rmdir()
|
||||||
|
|
||||||
|
def chmod(self, path, mode):
|
||||||
|
self._real(path).chmod(mode)
|
||||||
|
|
||||||
|
def put(self, local, remote, callback=None):
|
||||||
|
shutil.copyfile(local, self._real(remote))
|
||||||
|
if callback:
|
||||||
|
callback(os.path.getsize(local), os.path.getsize(local))
|
||||||
|
|
||||||
|
def get(self, remote, local, callback=None):
|
||||||
|
shutil.copyfile(self._real(remote), local)
|
||||||
|
if callback:
|
||||||
|
callback(os.path.getsize(local), os.path.getsize(local))
|
||||||
|
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
remote_root = Path(tempfile.mkdtemp())
|
||||||
|
local_root = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
# contenu distant simule
|
||||||
|
(remote_root / "dossier_distant").mkdir()
|
||||||
|
(remote_root / "config.yml").write_text("cle: valeur\n", encoding="utf-8")
|
||||||
|
(remote_root / "gros_fichier.log").write_text("x" * 5000, encoding="utf-8")
|
||||||
|
# contenu local
|
||||||
|
(local_root / "a_envoyer.txt").write_text("contenu local\n", encoding="utf-8")
|
||||||
|
(local_root / "dossier_local").mkdir()
|
||||||
|
(local_root / "dossier_local" / "interne.txt").write_text("imbrique\n", encoding="utf-8")
|
||||||
|
|
||||||
|
panel = SFTPPanel(FakeSFTP(remote_root))
|
||||||
|
panel.local.navigate(str(local_root))
|
||||||
|
panel.resize(1000, 600)
|
||||||
|
|
||||||
|
print("=== 1. Listing distant avec colonnes ===")
|
||||||
|
tree = panel.remote.tree
|
||||||
|
for i in range(tree.topLevelItemCount()):
|
||||||
|
it = tree.topLevelItem(i)
|
||||||
|
print(f" {it.text(0):<28} | {it.text(1):>9} | {it.text(2):<17} | {it.text(3)}")
|
||||||
|
assert tree.topLevelItemCount() == 4, "attendu .. + 3 entrees"
|
||||||
|
print(" -> colonnes Nom/Taille/Modifie/Droits presentes:", all(tree.headerItem().text(c) for c in range(4)))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 2. Envoi par glisser-deposer (local -> distant) ===")
|
||||||
|
payload = {"side": "local", "entries": [{"name": "a_envoyer.txt", "is_dir": False}]}
|
||||||
|
panel._on_transfer_payload(payload)
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline and panel._worker and panel._worker.isRunning():
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.02)
|
||||||
|
app.processEvents()
|
||||||
|
sent = (remote_root / "a_envoyer.txt")
|
||||||
|
print(" fichier arrive sur le serveur:", sent.exists())
|
||||||
|
assert sent.exists()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 3. Envoi recursif d'un dossier ===")
|
||||||
|
panel._on_transfer_payload({"side": "local", "entries": [{"name": "dossier_local", "is_dir": True}]})
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline and panel._worker and panel._worker.isRunning():
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.02)
|
||||||
|
app.processEvents()
|
||||||
|
nested = remote_root / "dossier_local" / "interne.txt"
|
||||||
|
print(" dossier + fichier imbrique transferes:", nested.exists())
|
||||||
|
assert nested.exists()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 4. Reception (distant -> local) ===")
|
||||||
|
panel._on_transfer_payload({"side": "remote", "entries": [{"name": "config.yml", "is_dir": False}]})
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline and panel._worker and panel._worker.isRunning():
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.02)
|
||||||
|
app.processEvents()
|
||||||
|
got = local_root / "config.yml"
|
||||||
|
print(" fichier recupere en local:", got.exists(), "| contenu identique:",
|
||||||
|
got.read_text(encoding="utf-8") == "cle: valeur\n")
|
||||||
|
assert got.exists()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 5. Renommage distant ===")
|
||||||
|
panel.remote.navigate("/")
|
||||||
|
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()
|
||||||
|
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])
|
||||||
|
assert "config_renomme.yml" in names
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 6. Permissions (chmod) ===")
|
||||||
|
panel.remote.sftp.chmod("/config_renomme.yml", 0o600)
|
||||||
|
mode = statmod.S_IMODE((remote_root / "config_renomme.yml").stat().st_mode)
|
||||||
|
print(f" droits appliques: {mode:03o}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 7. Suppression recursive distante ===")
|
||||||
|
panel.remote._rmdir_recursive("/dossier_local")
|
||||||
|
panel.remote.refresh()
|
||||||
|
print(" dossier supprime du serveur:", not (remote_root / "dossier_local").exists())
|
||||||
|
assert not (remote_root / "dossier_local").exists()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 8. Entree '..' presente pour remonter ===")
|
||||||
|
has_parent = any(panel.remote.tree.topLevelItem(i).data(0, PARENT_ROLE)
|
||||||
|
for i in range(panel.remote.tree.topLevelItemCount()))
|
||||||
|
print(" ligne '..' presente:", has_parent)
|
||||||
|
assert has_parent
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("TOUS LES TESTS SFTP OK")
|
||||||
Reference in New Issue
Block a user