Compare commits

..

4 Commits

Author SHA1 Message Date
Florian
aa890c5847 SFTP sur cette connexion : ne plus perdre la cible ssh detectee
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur avec une capture d'ecran : bouton "SFTP sur cette
connexion" clique depuis un terminal local visiblement connecte en ssh
(prompt root@debian:~#), formulaire de connexion vide a l'arrivee - "si je
dois remplir le formulaire pour une session actuelle ca perd tout son sens".

Cause reelle : detect_ssh_target() ne scrutait le texte du terminal qu'au
moment du clic (buffer visible + historique pyte, 5000 lignes). Si la ligne
`ssh user@hote` tapee par l'utilisateur n'y figure plus au moment du clic
(beaucoup de sortie depuis, ecran efface...), plus rien n'est detectable -
alors meme que la connexion ssh est toujours active. La capture montree
correspond exactement a ce cas : un ecran efface apres la connexion.

Corrige a la racine plutot qu'en agrandissant simplement l'historique
scrute : app/ui/tabs.py (BaseTerminalTab) memorise desormais la cible des
qu'une commande ssh apparait dans la sortie recue (_track_ssh_target,
cablee comme terminal.feed sur data_received), et ne l'oublie plus jamais -
detect_current_ssh_target() la renvoie en priorite, avec un dernier examen du
buffer actuel seulement en repli. Le bouton "SFTP sur cette connexion" et le
raccourci global Ctrl+Shift+F (ConnectionController._current_tab_ssh_target)
utilisent tous les deux ce nouveau point d'entree unique.

tests/test_ssh_target_tracking.py (4 verifications, nouveau fichier - ce
bouton n'avait encore aucun test dedie) : memorisation a la frappe, cible
toujours renvoyee meme quand le buffer live ne la contient plus, mise a jour
vers une commande ssh plus recente, bouton emettant bien la cible memorisee.
Suite complete revalidee (36/36), ruff propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 09:11:35 +02:00
Florian
ef059647fa Reduire la taille du formulaire de session (connexion rapide SFTP/SSH)
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur : le dialogue de "SFTP sur cette connexion" ne
tenait pas sur l'ecran. Deux causes distinctes, verifiees par capture d'ecran
avant de conclure (rendu hors-ecran + grab()) :

1. Bug reel, dans les deux dialogues qui ont une section Bastion (SessionDialog
   et GroupSettingsDialog) : leur QStackedWidget reservait par defaut la
   hauteur de sa PLUS GRANDE page (Configuration manuelle, 5 champs) meme
   quand la page vide "Aucun" etait affichee - environ 200px de vide pur,
   visible sur la premiere capture. Nouveau app/ui/auto_height_stack.py
   (AutoHeightStackedWidget) : le sizeHint suit la page COURANTE, pas la plus
   grande de toutes: -186px des l'ouverture, a bastion desactive.

2. Choix d'ergonomie : une connexion rapide sert a aller vite (souvent un
   serveur de test en DHCP, cf. le README) - Bastion/Keepalive/Reconnexion y
   sont maintenant repliees par defaut derriere un bouton "Options
   avancees...", toujours accessibles mais qui n'allongent plus le dialogue
   pour le cas courant. Se deplient automatiquement si "Heriter du groupe"
   ramene un bastion herite, pour ne pas cacher un changement silencieux.
   Le dialogue complet ("Nouvelle session" depuis le panneau lateral) est
   inchange : tout reste visible d'emblee, c'est une operation reflechie, pas
   une connexion a la volee.

Resultat mesure : dialogue de connexion rapide 1026px -> 492px replie (-52%),
876px si depliees (au lieu de 1026, meme contenu, sans le vide). Le dialogue
"Nouvelle session" complet passe de meme a 816px grace au seul correctif (1).

tests/test_session_dialog_compact.py (5 verifications) : sizeHint de
AutoHeightStackedWidget sur la page courante, repli par defaut en connexion
rapide, redimensionnement au depli, absence de repli hors connexion rapide,
depli automatique sur heritage de groupe. Suite complete revalidee (35/35),
ruff propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:54:31 +02:00
Florian
a65b86e25b Assistant IA : se rabattre sur l'ecran visible si rien n'est selectionne
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur : "Expliquer cette sortie" n'avait aucun effet
utilisable, le menu restait grise. Cause reelle : ces actions exigeaient une
selection prealable dans le terminal, alors que l'attente naturelle ("explique
ce que j'ai a l'ecran") est de pouvoir les utiliser directement sur ce qui est
affiche - confirme avec l'utilisateur avant d'implementer (deux options
proposees : rabattre sur l'ecran visible, ou juste clarifier le blocage).

app/terminal_widget.py : nouvelle methode visible_text() (l'ecran actuellement
affiche, sans l'historique de scroll - au contraire de buffer_text() qui sert
a la detection de commande ssh et remonte l'historique proche).

app/ui/tabs.py : Expliquer/Diagnostiquer/Proposer une solution/Poser une
question utilisent desormais _ai_source_text() = la selection si elle existe
(pour cibler precisement), sinon l'ecran visible. Les 3 premieres actions du
menu ne sont plus desactivees des qu'il n'y a pas de selection, mais
seulement si vraiment rien n'est affiche ; "Poser une question" reste
utilisable meme un terminal vide (contexte optionnel, comme le bouton
"Nouvelle question" du panneau).

tests/test_ai_tab_integration.py : reecriture des tests 1 et 2 (etats
d'activation du menu) + 2 nouveaux tests verifiant explicitement le repli
(sans selection, le texte envoye est bien celui de l'ecran) et sa priorite
inverse (une selection existante prime toujours sur l'ecran visible).
tests/test_clipboard.py : couverture directe de visible_text() (ecran complet,
lignes vides de fin retirees). Suite complete revalidee (34/34), ruff propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:37:05 +02:00
Florian
add1073e37 Corriger la fleche invisible des menus deroulants (QComboBox)
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur sur le nouveau champ "Fournisseur" de l'assistant
IA : aucune fleche visible, indiscernable d'un simple champ de texte - alors
que la liste s'ouvre bien au clic (le bug est purement visuel).

Cause : QComboBox::drop-down { border: none; width: 22px; } dans theme.qss
suffit a faire que Fusion (le style applique a toute l'application) arrete de
dessiner sa fleche par defaut. Ce n'est pas specifique au nouveau champ : tous
les QComboBox de l'application (authentification, cle SSH, categorie de
snippet, filtre de tags...) etaient concernes de la meme facon.

Tente d'abord de redessiner la fleche en QSS pur (triangle via des bordures) :
verifie visuellement (rendu hors-ecran + capture d'image) avant de conclure -
rendu un simple rectangle plein, pas un triangle, ce style ne supporte pas
cette technique correctement. Solution retenue : supprimer entierement la
regle ::drop-down plutot que de la remplacer, ce qui laisse Fusion dessiner
nativement sa propre fleche - verifie sur les 3 themes (clair/bleu nuit/noir),
lisible et bien contrastee partout sans configuration supplementaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:16:36 +02:00
12 changed files with 418 additions and 42 deletions

View File

@@ -116,11 +116,13 @@ n'est jamais executee automatiquement**.
autre LLM local...). Le modele, lui, reste toujours en saisie libre (pas de liste figee, chaque
installation Ollama/LM Studio ayant ses propres modeles telecharges). Bouton "Tester la
connexion" pour verifier la configuration avant de l'utiliser
- Depuis un onglet terminal, le bouton "🤖 Assistant IA" permet, sur la selection courante :
**Expliquer cette sortie**, **Diagnostiquer cette erreur**, **Proposer une solution**, ou
**Poser une question** en y ajoutant la selection comme contexte. Les reponses s'affichent
dans un panneau lateral (conversation en memoire uniquement, jamais journalisee ni ecrite sur
le disque)
- Depuis un onglet terminal, le bouton "🤖 Assistant IA" permet : **Expliquer cette sortie**,
**Diagnostiquer cette erreur**, **Proposer une solution**, ou **Poser une question**. Ces
actions portent sur le texte **selectionne** s'il y en a un (pour cibler precisement une
partie d'une sortie longue), et se rabattent sinon automatiquement sur **tout l'ecran visible
du terminal** — inutile de selectionner quoi que ce soit pour un simple "explique-moi ce que
j'ai sous les yeux". Les reponses s'affichent dans un panneau lateral (conversation en memoire
uniquement, jamais journalisee ni ecrite sur le disque)
- **Generer une commande** (a partir d'une description en langage naturel, sans selection
necessaire) affiche toujours la commande proposee avant tout usage, avec trois choix : la
**copier**, l'**inserer dans le terminal** (le texte est ecrit, jamais valide automatiquement),

View File

@@ -13,7 +13,6 @@ from app.dialogs.session_dialog import SessionDialog
from app.key_manager import KeyManager
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
from app.openssh.config import OpenSSHHost, session_from_openssh_host
from app.services.ssh_target import detect_ssh_target
from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore
from app.ui.split_pane import LAYOUT_LABELS, PaneSlot, SplitContainer
from app.ui.tabs import BaseTerminalTab, LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget
@@ -181,7 +180,7 @@ class ConnectionController(QObject):
"port": widget.session.port,
}
if isinstance(widget, BaseTerminalTab):
return detect_ssh_target(widget.terminal.buffer_text())
return widget.detect_current_ssh_target()
return None
def _quick_connect(self, sftp: bool, target: dict | None = None) -> None:

View File

@@ -13,7 +13,6 @@ from PySide6.QtWidgets import (
QLineEdit,
QPushButton,
QSpinBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
@@ -33,6 +32,7 @@ from app.session_manager import (
BastionConfig,
SessionStore,
)
from app.ui.auto_height_stack import AutoHeightStackedWidget
class GroupSettingsDialog(QDialog):
@@ -149,7 +149,7 @@ class GroupSettingsDialog(QDialog):
self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL)
v.addWidget(self.bastion_mode_combo)
self.bastion_stack = QStackedWidget()
self.bastion_stack = AutoHeightStackedWidget()
v.addWidget(self.bastion_stack)
self.bastion_stack.addWidget(QWidget())

View File

@@ -16,7 +16,6 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QSpinBox,
QStackedWidget,
QTextEdit,
QVBoxLayout,
QWidget,
@@ -41,6 +40,7 @@ from app.session_manager import (
Session,
SessionStore,
)
from app.ui.auto_height_stack import AutoHeightStackedWidget
class SessionDialog(QDialog):
@@ -147,9 +147,28 @@ class SessionDialog(QDialog):
self.form = form
layout.addLayout(form)
layout.addWidget(self._build_bastion_group())
layout.addWidget(self._build_keepalive_group())
layout.addWidget(self._build_reconnect_group())
advanced_container = QWidget()
advanced_layout = QVBoxLayout(advanced_container)
advanced_layout.setContentsMargins(0, 0, 0, 0)
advanced_layout.addWidget(self._build_bastion_group())
advanced_layout.addWidget(self._build_keepalive_group())
advanced_layout.addWidget(self._build_reconnect_group())
if self.quick_mode:
# Repliees par defaut : une connexion rapide sert avant tout a
# aller vite (souvent un serveur de test en DHCP) - bastion,
# keepalive et reconnexion restent accessibles sans allonger le
# dialogue pour le cas courant, ou ils ne servent pas.
self.advanced_toggle = QPushButton("▸ Options avancees (bastion, keepalive, reconnexion)")
self.advanced_toggle.setCheckable(True)
self.advanced_toggle.setFlat(True)
self.advanced_toggle.toggled.connect(self._on_advanced_toggled)
layout.addWidget(self.advanced_toggle)
advanced_container.setVisible(False)
layout.addWidget(advanced_container)
self.advanced_container = advanced_container
self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)")
if self.quick_mode:
@@ -177,6 +196,14 @@ class SessionDialog(QDialog):
self.key_combo.addItem(self.session.key_path, self.session.key_path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
def _on_advanced_toggled(self, checked: bool) -> None:
self.advanced_container.setVisible(checked)
self.advanced_toggle.setText(
"▾ Options avancees (bastion, keepalive, reconnexion)" if checked
else "▸ Options avancees (bastion, keepalive, reconnexion)"
)
self.adjustSize()
def _reload_keys(self) -> None:
self.key_combo.clear()
for entry in self.key_manager.keys:
@@ -243,6 +270,11 @@ class SessionDialog(QDialog):
self.keepalive_interval_spin.setValue(settings.keepalive_interval or 30)
self.keepalive_count_spin.setValue(settings.keepalive_count_max or 3)
# les reglages avances viennent d'etre modifies : les montrer, sans quoi
# ils resteraient caches derriere le repli "Options avancees"
if self.quick_mode and not self.advanced_toggle.isChecked():
self.advanced_toggle.setChecked(True)
# ---------- Bastion / Jump Host ----------
def _build_bastion_group(self) -> QGroupBox:
"""Un seul saut pour l'instant (voir BastionConfig) : Aucun / une session
@@ -258,7 +290,7 @@ class SessionDialog(QDialog):
self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL)
v.addWidget(self.bastion_mode_combo)
self.bastion_stack = QStackedWidget()
self.bastion_stack = AutoHeightStackedWidget()
v.addWidget(self.bastion_stack)
self.bastion_stack.addWidget(QWidget()) # BASTION_NONE : rien a afficher

View File

@@ -286,6 +286,16 @@ class TerminalWidget(QWidget):
payload = text.replace("\r\n", "\r").replace("\n", "\r")
self.data_to_send.emit(payload.encode("utf-8", errors="ignore"))
def visible_text(self) -> str:
"""Ce qui est actuellement affiche a l'ecran (sans l'historique de
defilement) : repli de l'assistant IA quand rien n'est selectionne -
"expliquer cette sortie" doit alors porter sur ce que l'utilisateur a
reellement sous les yeux, pas sur tout le scrollback."""
lines = [line.rstrip() for line in self.screen.display]
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)
def buffer_text(self) -> str:
"""Texte visible + historique proche, pour retrouver ce qui a ete tape
(utilise pour deduire l'hote d'une commande ssh saisie a la main).

View File

@@ -0,0 +1,31 @@
"""QStackedWidget qui s'adapte a la hauteur de sa page courante.
Par defaut, QStackedWidget dimensionne son sizeHint() sur sa PLUS GRANDE page,
meme quand une page plus petite (ou vide) est affichee - pense pour eviter
qu'un dialogue ne saute de taille en changeant de page. Ici on veut au
contraire un dialogue qui suit sa page courante : utilise pour les blocs
Bastion/Jump Host (session_dialog.py, group_settings_dialog.py), ou la page
"Aucun" est vide mais la page "Configuration manuelle" a 5 champs - sans ce
correctif, un grand espace vide reste reserve sous la page vide.
"""
from __future__ import annotations
from PySide6.QtWidgets import QStackedWidget
class AutoHeightStackedWidget(QStackedWidget):
def sizeHint(self):
return self.currentWidget().sizeHint()
def minimumSizeHint(self):
return self.currentWidget().minimumSizeHint()
def setCurrentIndex(self, index: int) -> None:
super().setCurrentIndex(index)
# previent le parent (le dialogue) que sizeHint() a change, sans quoi
# il ne redemande pas immediatement la nouvelle taille au changement
# de page.
self.updateGeometry()
window = self.window()
if window is not None:
window.adjustSize()

View File

@@ -151,6 +151,7 @@ class BaseTerminalTab(QWidget):
self._log_file = None
self._connection_generation = 0
self._closing = False
self._last_ssh_target: dict | None = None
# ---------- journalisation ----------
def toggle_logging(self) -> None:
@@ -193,6 +194,24 @@ class BaseTerminalTab(QWidget):
except OSError:
self.stop_logging()
# ---------- detection d'une commande ssh tapee a la main ----------
def _track_ssh_target(self, _data: bytes) -> None:
"""Reevalue a chaque sortie recue, et ne fait que memoriser un
resultat trouve (jamais l'oublier) : la commande `ssh ...` typee par
l'utilisateur peut ensuite defiler hors de l'ecran visible, voire hors
de l'historique conserve (beaucoup de sortie, `clear`...), longtemps
avant qu'on ait besoin de s'en souvenir (bouton SFTP sur cette
connexion)."""
target = detect_ssh_target(self.terminal.buffer_text())
if target:
self._last_ssh_target = target
def detect_current_ssh_target(self) -> dict | None:
"""Cible ssh de ce terminal : la derniere commande ssh vue passer
(memorisee au moment ou elle etait encore visible), sinon un dernier
examen du texte actuellement disponible."""
return self._last_ssh_target or detect_ssh_target(self.terminal.buffer_text())
def _wire_connection(self) -> None:
# Un signal deconnexion/erreur/connexion emis par une connexion remplacee
# entre-temps (ex. reconnexion en cours) ne doit pas ecraser l'etat de la
@@ -201,6 +220,7 @@ class BaseTerminalTab(QWidget):
generation = self._connection_generation
self.connection.connected.connect(lambda: self._on_current_generation(generation, self._on_connected))
self.connection.data_received.connect(self.terminal.feed)
self.connection.data_received.connect(self._track_ssh_target)
self.connection.data_received.connect(self._log_output)
self.connection.disconnected.connect(
lambda reason: self._on_current_generation(generation, self._on_disconnected, reason)
@@ -301,30 +321,43 @@ class BaseTerminalTab(QWidget):
menu = self._build_ai_menu()
menu.exec(self.ai_btn.mapToGlobal(self.ai_btn.rect().bottomLeft()))
def _ai_source_text(self) -> str:
"""Texte selectionne s'il y en a un (pour cibler precisement une
partie de la sortie), sinon tout l'ecran actuellement visible - pour
que "Expliquer cette sortie" porte par defaut sur ce que l'utilisateur
a reellement sous les yeux, sans obliger a selectionner au prealable."""
selection = self.terminal.selected_text()
return selection if selection else self.terminal.visible_text()
def _build_ai_menu(self) -> QMenu:
menu = QMenu(self)
has_selection = self.terminal.has_selection()
has_content = bool(self._ai_source_text().strip())
tooltip = (
"Utilise le texte selectionne, ou tout l'ecran visible si rien n'est selectionne."
if has_content else "Rien n'est encore affiche dans ce terminal."
)
explain_action = menu.addAction("Expliquer cette sortie")
explain_action.setEnabled(has_selection)
explain_action.setEnabled(has_content)
explain_action.setToolTip(tooltip)
explain_action.triggered.connect(self._ai_explain)
diagnose_action = menu.addAction("Diagnostiquer cette erreur")
diagnose_action.setEnabled(has_selection)
diagnose_action.setEnabled(has_content)
diagnose_action.setToolTip(tooltip)
diagnose_action.triggered.connect(self._ai_diagnose)
solution_action = menu.addAction("Proposer une solution")
solution_action.setEnabled(has_selection)
solution_action.setEnabled(has_content)
solution_action.setToolTip(tooltip)
solution_action.triggered.connect(self._ai_solution)
ask_action = menu.addAction("Poser une question...")
ask_action.setEnabled(has_selection)
ask_action.setToolTip(
"La selection (ou l'ecran visible a defaut) est jointe comme contexte a la question."
)
ask_action.triggered.connect(self._ai_ask)
if not has_selection:
for action in (explain_action, diagnose_action, solution_action, ask_action):
action.setToolTip("Selectionnez d'abord du texte dans le terminal.")
menu.addSeparator()
generate_action = menu.addAction("Generer une commande...")
generate_action.triggered.connect(self._ai_generate_command)
@@ -367,18 +400,15 @@ class BaseTerminalTab(QWidget):
self._run_ai_selection_action(KIND_SOLUTION, "Proposer une solution")
def _run_ai_selection_action(self, kind: str, label: str) -> None:
selection = self.terminal.selected_text()
if not selection:
text = self._ai_source_text()
if not text.strip():
return
panel = self._ensure_ai_panel()
panel.run_request(kind, selection, label=label)
panel.run_request(kind, text, label=label)
def _ai_ask(self) -> None:
selection = self.terminal.selected_text()
if not selection:
return
panel = self._ensure_ai_panel()
panel.prompt_question(context=selection)
panel.prompt_question(context=self._ai_source_text())
def _ai_generate_command(self) -> None:
if self._ai_command_worker is not None and self._ai_command_worker.isRunning():
@@ -780,7 +810,7 @@ class LocalTerminalTabWidget(BaseTerminalTab):
"Ouvre un gestionnaire de fichiers SFTP sur l'hote de la commande ssh tapee ici"
)
self.sftp_btn.clicked.connect(
lambda: self.sftp_requested.emit(detect_ssh_target(self.terminal.buffer_text()))
lambda: self.sftp_requested.emit(self.detect_current_ssh_target())
)
self.bar.addWidget(self.sftp_btn)

View File

@@ -235,10 +235,13 @@ QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QComboBox {
QLineEdit:focus, QTextEdit:focus, QSpinBox:focus, QComboBox:focus {
border: 1px solid $accent;
}
QComboBox::drop-down {
border: none;
width: 22px;
}
/* Pas de regle ::drop-down/::down-arrow ici : des qu'on personnalise
::drop-down (meme juste border/width), Fusion (le style de toute l'appli)
arrete de dessiner sa fleche par defaut, et la redessiner soi-meme en QSS
pur (bordures en triangle) ne rend pas correctement avec ce style - un
simple rectangle plein a la place d'un triangle. Laisser Fusion gerer
entierement ce sous-controle donne une fleche nette, qui suit deja la
couleur de texte du champ ($text) sans configuration supplementaire. */
QComboBox QAbstractItemView {
background-color: $input;
color: $text;

View File

@@ -49,34 +49,74 @@ def make_tab(ai_provider_factory=None):
return LocalTerminalTabWidget(ai_provider_factory=ai_provider_factory)
print("=== 1. Menu IA : actions de selection desactivees sans selection ===")
print("=== 1. Menu IA : actions desactivees sur un terminal vraiment vide (rien affiche, rien selectionne) ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
assert not tab.terminal.has_selection()
assert tab.terminal.visible_text() == ""
menu = tab._build_ai_menu()
labels = [a.text() for a in menu.actions() if a.text()]
print(" actions:", labels)
assert "Expliquer cette sortie" in labels
assert "Generer une commande..." in labels
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution"):
action = next(a for a in menu.actions() if a.text() == text)
assert not action.isEnabled(), f"{text} devrait etre desactive sans selection"
assert not action.isEnabled(), f"{text} devrait etre desactive : rien a analyser"
# "Poser une question" reste utilisable sans rien : le contexte est optionnel
ask_action = next(a for a in menu.actions() if a.text() == "Poser une question...")
assert ask_action.isEnabled()
generate_action = next(a for a in menu.actions() if a.text() == "Generer une commande...")
assert generate_action.isEnabled(), "Generer une commande ne necessite pas de selection"
tab.close_connection()
print(" OK")
print()
print("=== 2. Menu IA : actions de selection activees des qu'il y a une selection ===")
print("=== 2. Menu IA : actions activees des qu'il y a du texte visible, meme sans selection ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
tab.terminal.feed(b"erreur : connection refused\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0)
assert tab.terminal.has_selection()
assert not tab.terminal.has_selection()
assert tab.terminal.visible_text().strip()
menu = tab._build_ai_menu()
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
action = next(a for a in menu.actions() if a.text() == text)
assert action.isEnabled(), f"{text} devrait etre active avec une selection"
assert action.isEnabled(), f"{text} devrait etre active : l'ecran visible sert de repli"
tab.close_connection()
print(" OK")
print()
print("=== 2bis. 'Expliquer cette sortie' sans selection : se rabat sur l'ecran visible ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="Ceci indique une connexion refusee.")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"erreur : connection refused\r\n")
assert not tab.terminal.has_selection(), "ce test verifie precisement le cas SANS selection"
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
assert isinstance(tab.ai_panel, AIPanel)
conversation = tab.ai_panel.conversation.toPlainText()
print(" conversation:", conversation)
assert "connection refused" in conversation.lower() # le texte envoye est bien celui de l'ecran
assert "connexion refusee" in conversation.lower() # et la reponse simulee est bien affichee
tab.close_connection()
print(" OK")
print()
print("=== 2ter. Une selection existante reste prioritaire sur l'ecran visible ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="reponse")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"ligne 1 sans rapport\r\nerreur precise\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 1), (13, 1)
assert tab.terminal.selected_text() == "erreur precise"
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
conversation = tab.ai_panel.conversation.toPlainText()
print(" conversation:", conversation)
assert "erreur precise" in conversation.lower()
assert "ligne 1 sans rapport" not in conversation.lower()
tab.close_connection()
print(" OK")

View File

@@ -36,6 +36,15 @@ print(" lignes:", lignes)
assert lignes[0].startswith("root@srv"), lignes
assert lignes[1] == "serveur-test-01", lignes
print()
print("=== 3bis. visible_text() : tout l'ecran, sans les lignes vides de fin ===")
visible = t.visible_text()
print(" ecran visible:", repr(visible))
lignes_visibles = visible.split("\n")
assert lignes_visibles[0].startswith("root@srv"), lignes_visibles
assert lignes_visibles[1] == "serveur-test-01", lignes_visibles
assert lignes_visibles[-1].startswith("root@srv"), "le prompt courant doit etre la derniere ligne non vide"
print()
print("=== 4. Collage (le presse-papiers part vers le shell) ===")
envoye = []

View File

@@ -0,0 +1,125 @@
"""Compacite du formulaire de session : signale par l'utilisateur ("trop grand,
ne rentre pas dans l'ecran") sur le dialogue de connexion SFTP rapide.
Deux causes distinctes corrigees ensemble :
1. AutoHeightStackedWidget : le QStackedWidget de la section Bastion reservait
par defaut la hauteur de sa PLUS GRANDE page (Configuration manuelle, 5
champs) meme quand la page vide ("Aucun") etait affichee - un rectangle
vide d'environ 200px, repere en comparant une capture d'ecran avant/apres.
2. En connexion rapide (quick_mode), Bastion/Keepalive/Reconnexion sont
repliees par defaut derriere un bouton "Options avancees..." : peu
pertinentes pour une connexion ponctuelle (souvent un serveur de test)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from app.dialogs.session_dialog import SessionDialog
from app.groups import GroupSettings, GroupSettingsStore
from app.key_manager import KeyManager
from app.session_manager import AUTH_AUTO, BASTION_MANUAL, BastionConfig, Session, SessionStore
from app.ui.auto_height_stack import AutoHeightStackedWidget
app = QApplication.instance() or QApplication(sys.argv)
tmp = Path(tempfile.mkdtemp())
key_manager = KeyManager(registry_path=tmp / "keys.json")
store = SessionStore(path=tmp / "sessions.json")
def make_page(height: int) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
label = QLabel("x")
label.setFixedHeight(height)
layout.addWidget(label)
return page
print("=== 1. AutoHeightStackedWidget : sizeHint suit la page courante, pas la plus grande ===")
stack = AutoHeightStackedWidget()
small_page = make_page(10)
tall_page = make_page(300)
stack.addWidget(small_page)
stack.addWidget(tall_page)
stack.setCurrentIndex(0)
height_on_small = stack.sizeHint().height()
stack.setCurrentIndex(1)
height_on_tall = stack.sizeHint().height()
print(f" hauteur sur petite page: {height_on_small}, sur grande page: {height_on_tall}")
assert height_on_small < height_on_tall, "la page courante doit determiner la hauteur, pas la plus grande des deux"
assert height_on_tall >= 300
print(" OK")
print()
print("=== 2. Connexion rapide : les options avancees sont repliees par defaut ===")
dlg = SessionDialog(
key_manager, session=Session(), parent=None, connect_label="Se connecter",
title="Connexion SFTP", session_store=store,
)
assert hasattr(dlg, "advanced_toggle"), "le bouton de repli doit exister en mode connexion rapide"
assert not dlg.advanced_toggle.isChecked()
assert not dlg.advanced_container.isVisible()
collapsed_height = dlg.sizeHint().height()
print(" hauteur repliee:", collapsed_height)
dlg.close()
print(" OK")
print()
print("=== 3. Deplier affiche les reglages avances et agrandit le dialogue ===")
dlg = SessionDialog(
key_manager, session=Session(), parent=None, connect_label="Se connecter",
title="Connexion SFTP", session_store=store,
)
dlg.show() # isVisible() se compose avec les ancetres : il en faut un reellement affiche
dlg.adjustSize()
collapsed = dlg.height()
dlg.advanced_toggle.setChecked(True)
app.processEvents()
expanded = dlg.height()
print(f" hauteur repliee: {collapsed}, depliee: {expanded}")
assert dlg.advanced_container.isVisible()
assert "▾" in dlg.advanced_toggle.text()
assert expanded > collapsed
dlg.close()
print(" OK")
print()
print("=== 4. Nouvelle session complete (pas de connexion rapide) : tout est visible, pas de repli ===")
dlg = SessionDialog(key_manager, session=Session(), parent=None, session_store=store)
dlg.show()
assert not hasattr(dlg, "advanced_toggle"), "pas de bouton de repli hors connexion rapide"
assert dlg.advanced_container.isVisible()
dlg.close()
print(" OK")
print()
print("=== 5. Heriter du groupe en connexion rapide deplie automatiquement si un bastion est herite ===")
group_store = GroupSettingsStore(path=tmp / "groups.json")
group_store.set(GroupSettings(
path="Prod", username="deploy", auth_type=AUTH_AUTO,
bastion=BastionConfig(mode=BASTION_MANUAL, host="bastion.example.com", username="jump"),
))
dlg = SessionDialog(
key_manager, session=Session(), parent=None, connect_label="Se connecter",
title="Connexion SFTP", session_store=store, group_store=group_store,
)
dlg.show()
assert not dlg.advanced_toggle.isChecked()
assert not dlg.advanced_container.isVisible()
dlg.group_edit.setText("Prod")
dlg._apply_group_defaults()
print(" bouton avance coche apres heritage:", dlg.advanced_toggle.isChecked())
assert dlg.advanced_toggle.isChecked(), "l'heritage d'un bastion doit reveler les options avancees"
assert dlg.advanced_container.isVisible()
assert dlg.bastion_host_edit.text() == "bastion.example.com"
dlg.close()
print(" OK")
print()
print("SESSION DIALOG - COMPACITE : TOUS LES TESTS OK")

View File

@@ -0,0 +1,95 @@
"""Detection de la cible ssh "en cours" dans un terminal local : signale par
l'utilisateur (bouton "SFTP sur cette connexion" revenant a un formulaire
vide alors qu'une session ssh etait bien active a l'ecran).
Cause reelle : detect_ssh_target() ne scrutait le texte du terminal qu'au
moment du clic, en repartant du buffer/historique ACTUEL - si la ligne
`ssh user@hote` tapee par l'utilisateur en etait sortie entre-temps (beaucoup
de sortie, ecran efface...), plus rien n'etait detectable, meme si la
connexion ssh etait toujours active. Corrige en memorisant la cible des
qu'elle apparait dans la sortie (BaseTerminalTab._track_ssh_target), pour ne
plus dependre de ce qui est encore present dans le buffer au moment du clic.
Important : on alimente le terminal via connection.data_received.emit(...),
pas terminal.feed(...) directement - _track_ssh_target est cablee sur ce
signal (comme terminal.feed lui-meme), l'appeler directement contournerait le
suivi et ne testerait que le repli (deja fonctionnel avant ce correctif)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
from app.local_terminal import LocalTerminalConnection
from app.ui.tabs import LocalTerminalTabWidget
app = QApplication.instance() or QApplication(sys.argv)
def make_tab():
return LocalTerminalTabWidget()
def feed(tab, data: bytes) -> None:
tab.connection.data_received.emit(data)
print("=== 1. Une commande ssh tapee est memorisee des qu'elle apparait a l'ecran ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
assert tab.detect_current_ssh_target() is None
feed(tab, b"user@PC MINGW64 ~\r\n$ ssh root@debian\r\n")
target = tab.detect_current_ssh_target()
print(" cible detectee:", target)
assert target == {"user": "root", "host": "debian", "port": 22}
tab.close_connection()
print(" OK")
print()
print("=== 2. La cible reste memorisee meme si la commande disparait du buffer ensuite ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
feed(tab, b"$ ssh root@debian\r\n")
assert tab._last_ssh_target == {"user": "root", "host": "debian", "port": 22}
# simule la perte de la ligne d'origine (beaucoup de sortie, ecran efface...) :
# buffer_text() ne renvoie plus rien d'exploitable, mais la cible reste connue
with patch.object(type(tab.terminal), "buffer_text", return_value="root@debian:~# "):
target = tab.detect_current_ssh_target()
print(" cible apres 'perte' du buffer:", target)
assert target == {"user": "root", "host": "debian", "port": 22}
tab.close_connection()
print(" OK")
print()
print("=== 3. Une nouvelle commande ssh remplace bien l'ancienne cible memorisee ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
feed(tab, b"$ ssh root@debian\r\n")
assert tab._last_ssh_target["host"] == "debian"
feed(tab, b"logout\r\n$ ssh admin@192.168.1.50\r\n")
target = tab.detect_current_ssh_target()
print(" nouvelle cible:", target)
assert target == {"user": "admin", "host": "192.168.1.50", "port": 22}
tab.close_connection()
print(" OK")
print()
print("=== 4. Bouton 'SFTP sur cette connexion' : emet la cible memorisee, pas un formulaire vide ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
feed(tab, b"$ ssh root@debian\r\n")
with patch.object(type(tab.terminal), "buffer_text", return_value="root@debian:~# "):
received = []
tab.sftp_requested.connect(lambda t: received.append(t))
tab.sftp_btn.click()
print(" emis par le bouton:", received)
assert received == [{"user": "root", "host": "debian", "port": 22}]
tab.close_connection()
print(" OK")
print()
print("SSH TARGET TRACKING : TOUS LES TESTS OK")