Commit Graph

24 Commits

Author SHA1 Message Date
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
f5d99584fd Assistant IA : liste deroulante de fournisseurs (dont Ollama, LM Studio)
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
Retour utilisateur : le champ "Fournisseur" en simple texte libre ne donnait
aucune indication de ce qui est possible, notamment pour l'IA locale
(Ollama, LM Studio), alors que d'autres preferent un service cloud.

Remplace par une QComboBox editable : une liste de raccourcis usuels (OpenAI,
OpenRouter, Mistral AI, Ollama local, LM Studio local, Personnalise...) qui
pre-remplit l'URL de base au choix - sans jamais empecher de saisir librement
un autre fournisseur/proxy compatible OpenAI, conformement a l'exigence
d'origine de ne rien figer en dur. Le modele reste toujours en saisie libre,
chaque installation Ollama/LM Studio ayant ses propres modeles telecharges.

Bonus cible : choisir un prereglage local (Ollama/LM Studio, detecte via
localhost/127.0.0.1 dans l'URL) met a jour l'indication du champ cle API pour
signaler qu'elle n'est generalement pas necessaire.

tests/test_ai_settings_dialog.py (8 verifications, nouveau fichier - aucun
test ne couvrait encore AISettingsDialog elle-meme) : presence d'Ollama/LM
Studio dans la liste, pre-remplissage de l'URL par prereglage, "Personnalise"
qui ne touche pas une URL deja saisie, persistance d'un nom de fournisseur
personnalise a travers un enregistrement puis une reouverture. Suite complete
revalidee (34/34) et ruff toujours propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:00:25 +02:00
Florian
c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:46:50 +02:00
Florian
09b7ed092c Migration de la suite de tests vers pytest, sans reecrire les scripts existants
pytest + pytest-qt (requirements-dev.txt) deviennent le point d'entree unique
de la suite ("pytest" depuis la racine), sans toucher aux 30+ scripts
test_*.py existants (convention deliberement conservee - voir tests/conftest.py) :
tests/test_all_scripts.py les lance chacun comme sous-processus isole (comme
c'etait deja fait manuellement a chaque phase), avec QT_QPA_PLATFORM=offscreen
pour tourner sans affichage (verifie : la suite complete passe a l'identique
en mode hors-ecran, prerequis pour une future integration continue).

tests/run_all.bat corrige au passage : il ne lancait plus que 4 tests sur 30+
(liste figee, jamais mise a jour au fil des phases) ; il delegue maintenant a
pytest.

Audit de couverture (Phase 13 du plan) : fingerprint, stockage atomique,
migration de schema, config OpenSSH, ProxyJump, CredentialStore, redaction de
secrets et detection de commandes dangereuses etaient deja couverts au fil des
phases precedentes. Seul point manquant identifie : aucun test ne verifiait
qu'une cle API IA ne puisse fuiter dans un message d'erreur - ajoute dans
tests/test_no_secrets_in_errors.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:32:55 +02:00
Florian
0b2ff38bfd Assistant IA optionnel (analyse de sortie, diagnostic, generation de commande)
Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de
secrets, detection de commandes dangereuses, parametres, worker QThread) et son
integration dans chaque onglet terminal via un bouton "Assistant IA" (memes
conventions que le bouton Snippets existant) :

- Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution /
  Poser une question... : utilisent la selection courante du terminal, affichees
  dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le
  splitter comme le panneau SFTP.
- Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer
  dans le terminal sans Entree / Annuler), avec avertissement si la commande
  proposee correspond a un motif destructeur connu.
- Avant tout envoi : avis unique (persist) puis detection de secrets evidents
  (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler.

Desactive par defaut, sans liste de fournisseur figee (URL + modele libres,
compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par
CredentialStore (jamais dans settings.json ni journalisee). Parametrable via
le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion").

Bug trouve par les tests avant commit : un panneau nouvellement insere dans un
QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le
premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement
a sa creation.

9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker,
confirmations de securite, dialogue de commande, panneau, integration onglet) :
39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 05:48:13 +02:00
Florian
9216f636bd feat: ajouter le Credential Vault (memorisation securisee optionnelle)
Par defaut, inchange : les mots de passe sont toujours redemandes a chaque
connexion. Nouvelle case a cocher optionnelle dans l'editeur de session,
visible seulement en authentification par mot de passe : "Memoriser le mot
de passe de maniere securisee".

- app/credentials.py : abstraction CredentialStore (set_secret/get_secret/
  delete_secret/is_available), pour supporter plus tard d'autres systemes que
  Windows. WindowsCredentialStore utilise Windows Credential Manager
  (CredWrite/CredRead/CredDelete) via pywin32-ctypes - deja present dans
  l'environnement (dependance de PyInstaller), mais pas encore declare comme
  dependance d'execution : ajoute a requirements.txt (marque sys_platform ==
  "win32"), sinon la fonctionnalite aurait silencieusement ete indisponible
  sur une installation propre. NullCredentialStore en repli si indisponible :
  jamais d'ecriture en clair a la place.
- Session.password_storage ("none" par defaut | "secure") : seulement le mode
  est stocke dans sessions.json, jamais le secret. Le mot de passe est ecrit/
  lu dans le trousseau via une cle applicative (session:<id>), et supprime
  automatiquement si l'utilisateur desactive la memorisation ou supprime la
  session.
- Un bastion en mode "session PySSH" reutilise un mot de passe deja memorise
  pour cette session (lecture seule depuis ce chemin : l'ecriture ne se fait
  que via l'ouverture directe de la session concernee).

tests/test_credentials.py : NullCredentialStore, aller-retour reel avec
Windows Credential Manager (nettoye et verifie apres coup, aucune entree
residuelle), integration ConnectionController (memorise puis reutilise sans
redemander), suppression en cascade, reutilisation par un bastion, et
verification que le secret n'apparait jamais dans la serialisation JSON de
la session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:21:32 +02:00
Florian
b615ed52af feat: ajouter les tags de session et les parametres par defaut de groupe
Tags : une session peut porter plusieurs tags libres (Session.tags), saisis
comme une liste separee par des virgules dans l'editeur (normalises : espaces
retires, doublons insensibles a la casse elimines), avec autocompletion sur
les tags deja utilises. Affiches dans l'arbre des sessions, et filtrables via
un champ dedie au-dessus de l'arbre (masque les sessions ne correspondant pas,
sans toucher aux dossiers/terminaux locaux/hosts OpenSSH).

Parametres de groupe : choix architectural deliberement plus simple qu'une
resolution dynamique a la connexion. app/groups.py (GroupSettingsStore, meme
pattern que les autres stores) associe un dossier (Session.group) a des
valeurs par defaut (utilisateur, port, authentification, cle, bastion,
keepalive). Ces valeurs ne s'appliquent qu'au moment de la creation d'une
session, via un bouton explicite "↓ Heriter du groupe" dans SessionDialog -
jamais de resolution automatique/retroactive. Ce choix evite d'introduire des
valeurs sentinelles "herite vs. explicite" sur chaque champ (port=22 est a la
fois une valeur par defaut ET une vraie valeur possible, par exemple) dans
tout le reste du code (ConnectionController, SSHConnection...), pour une
fonctionnalite que la priorisation du projet place parmi les secondaires. Une
session reste ensuite un ensemble de valeurs normales, librement modifiables
- "surcharger une valeur heritee" est donc trivialement satisfait.

Clic droit sur un dossier dans le panneau lateral : "Nouvelle session dans ce
dossier..." (pre-remplit le champ Dossier) et "Parametres du groupe..."
(GroupSettingsDialog).

tests/test_tags_and_groups.py : persistance des tags, CRUD GroupSettingsStore,
normalisation de la saisie des tags, heritage applique/absent, filtrage dans
l'arbre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:10:01 +02:00
Florian
ff4a73e6e6 feat: ajouter la recherche rapide Ctrl+K (Quick Connect)
Palette globale recherchant a travers sessions, hosts OpenSSH, workspaces,
snippets et actions courantes, avec recherche floue et navigation
entierement au clavier (fleches, Entree, Echap).

- app/services/fuzzy.py : correspondance par sous-sequence (comme fzf, sans
  dependance externe), score favorisant les caracteres consecutifs et les
  debuts de mot.
- QuickConnectDialog : collecte les resultats depuis les stores/controleurs
  deja existants (aucune duplication de logique - activer une session appelle
  ConnectionController.open_session(), un workspace WorkspaceManager.restore(),
  un snippet insert_snippet() dans l'onglet courant, sans jamais l'executer
  automatiquement). Les actions (Nouvelle session, Gestionnaires...) reutilisent
  les methodes des controleurs/dialogues existants.
- Ctrl+K (menu Fichier > Recherche rapide...) ouvre la palette, reconstruite
  a chaque ouverture pour refleter l'etat courant.
- insert_snippet()/execute_snippet() rendus publics sur BaseTerminalTab
  (utilises depuis un autre module par la palette).

tests/test_fuzzy.py (scoring, tri par pertinence) et test_quick_connect.py
(collecte des 4 types de resultats + actions, filtrage, navigation clavier,
activation reelle d'une session et d'un snippet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:00:07 +02:00
Florian
0a07ddcb62 feat: ajouter les Snippets de commandes
Bibliotheque de commandes organisee par categories (Linux/Docker/Proxmox
fournis en exemples au premier lancement, librement modifiables), avec
recherche.

- app/snippets.py : Snippet/SnippetStore, meme pattern que les autres stores
  (JSON atomique, schema_version).
- SnippetManagerDialog : creer/modifier/supprimer/rechercher, regroupe par
  categorie.
- Integration terminal : le dernier commit du depot a delibrement fait du
  clic droit un raccourci de collage (style terminal Unix), sans menu
  contextuel. Plutot que d'ecraser ce choix recent, les snippets passent par
  un bouton dedie "📋 Snippets" dans la barre de l'onglet (SessionTabWidget et
  LocalTerminalTabWidget), avec un menu Categorie -> Snippet -> Action
  (Inserer dans le terminal / Executer / Executer avec Multi-Send).
  "Inserer" n'envoie jamais Entree (le texte est ecrit, a l'utilisateur de
  valider) ; seul "Executer" l'ajoute.
- BroadcastManager.broadcast_now() : diffusion explicite et ponctuelle vers
  tous les terminaux, independante de l'etat du bouton Multi (utilisee par
  "Executer avec Multi-Send", qui est une action volontaire et pas une frappe
  clavier a relayer).
- La construction du menu (_build_snippets_menu) est separee de son
  affichage (_show_snippets_menu) : testable sans declencher l'appel
  bloquant menu.exec().

tests/test_snippets.py : stockage (CRUD, recherche, categories, persistance)
et integration terminal (inserer/executer/multi-send, construction et
declenchement du menu), avec LocalTerminalConnection.start() neutralisee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:55:10 +02:00
Florian
a3c72ade0b feat: ajouter les Workspaces (enregistrer/restaurer l'etat des onglets)
Un workspace capture les onglets ouverts (sessions SSH, terminaux locaux,
splits avec leurs tailles) et les restaure plus tard. Ne reference les
sessions que par id : aucun mot de passe stocke, redemande a la restauration
exactement comme une ouverture normale.

- app/workspace.py : Workspace/WorkspaceTab/WorkspaceItem + WorkspaceStore
  (meme pattern que SessionStore/KeyManager : JSON atomique, schema_version).
- app/controllers/workspace_manager.py : capture() lit l'etat reel des onglets
  de ConnectionController (types de widgets, layout et tailles d'un
  SplitContainer) ; restore() rouvre chaque onglet via
  ConnectionController.build_session_tab()/build_sftp_tab()/new_local_terminal()
  (factorises hors de open_session()/open_sftp_session() pour etre reutilisables
  ici) puis reconstruit les splits avec split_tab(). Un element non restaurable
  (session supprimee depuis) est signale (compteur "ignores") plutot que de
  faire echouer toute la restauration.
- WorkspaceDialog : creer / enregistrer (ecraser) / ouvrir / renommer /
  supprimer, plus la case "Restaurer automatiquement le dernier workspace
  ouvert au demarrage" (desactivee par defaut).
- app/app_settings.py : preferences globales (theme, taille de police,
  dernier workspace) consolidees dans un seul module au lieu d'un mecanisme
  de lecture/ecriture duplique par prefernce dans theme.py.
- SplitContainer expose layout_kind et get_layout_sizes()/set_layout_sizes()
  (parcours recursif des QSplitter imbriques) pour la capture/restauration
  fidele d'une grille 2x2.

tests/test_workspace.py : capture (simple + split), sauvegarde/rechargement
JSON, restauration complete et partielle, renommer/supprimer/re-enregistrer,
restauration automatique - avec app_settings.SETTINGS_FILE redirige vers un
fichier temporaire pour ne jamais toucher les vrais reglages de l'utilisateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:47:07 +02:00
Florian
5917e1ca04 feat: ajouter le split terminal (plusieurs panneaux par onglet)
Layouts : 1 (normal), 2 verticaux (cote a cote), 2 horizontaux (empiles), 4
panneaux (grille 2x2), via des QSplitter imbriques (app/ui/split_pane.py).
Chaque panneau reutilise tel quel un widget d'onglet existant
(SessionTabWidget, LocalTerminalTabWidget) : aucune modification de ces
classes, seule une fine barre (menu ☰ + fermeture) les enveloppe.

- Menu contextuel d'un onglet (SSH ou terminal local, pas SFTP, pas
  d'imbrication de splits) : "Diviser" -> choix du layout. Le contenu actuel
  occupe le premier panneau, les autres recoivent un terminal local par
  defaut.
- Menu ☰ d'un panneau : changer son contenu (autre terminal local), ou
  "Deplacer un onglet ici" pour y faire migrer un onglet de premier niveau
  existant sans fermer sa connexion.
- Fermer un panneau le retire de son QSplitter (qui redistribue l'espace tout
  seul) ; s'il n'en reste qu'un, l'onglet redevient un onglet normal
  (redescente automatique, pas de chrome de split residuel).
- Fermer l'onglet divise (bouton x) ferme toutes les connexions des panneaux.
- BroadcastManager (Multi-Send) atteint desormais aussi les panneaux a
  l'interieur d'un split (MainWindow._iter_tabs les deplie).

Bug intercepte par les tests avant integration : reparenter le widget d'origine
dans son panneau retire automatiquement son onglet du QTabWidget (un widget ne
peut avoir qu'un seul parent), ce qui decale les onglets suivants ; un appel
explicite a removeTab() ensuite visait donc le mauvais onglet. Corrige en
laissant Qt gerer ce retrait implicitement plutot que de le dupliquer.

tests/test_split_terminal.py : SSHConnection.start()/LocalTerminalConnection.start()
neutralisees (teste l'agencement des panneaux et le cycle de vie des onglets,
pas une connexion reelle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:35:39 +02:00
Florian
9c8017cebd feat: ajouter le keepalive SSH et la reconnexion automatique
- Session gagne keepalive_enabled/interval/count_max (actif par defaut : ne
  fait qu'envoyer des paquets de maintien, sans risque pour les sessions
  existantes) et reconnect_enabled/initial_delay/max_attempts (desactive par
  defaut : change reellement le comportement en cas de coupure, donc a
  activer explicitement).

- SSHConnection._keepalive_loop() : thread dedie qui envoie
  "keepalive@openssh.com" (comme OpenSSH) avec accuse de reception toutes les
  `keepalive_interval` secondes. Un serveur conforme repond immediatement
  (meme en echec), ce qui confirme que le transport est vivant. Apres
  `keepalive_count_max` echecs consecutifs (aucune reponse), le canal est
  ferme pour que la boucle de lecture principale s'en apercoive et emette
  `disconnected` avec une raison explicite.

- SessionTabWidget programme une reconnexion (QTimer, jamais de blocage de
  l'interface) quand la session l'autorise et que la deconnexion n'est pas
  volontaire (_closing) : delai en backoff exponentiel plafonne
  (app.services.reconnect.compute_backoff_delay), compte a rebours affiche
  ("Connexion perdue (...). Reconnexion dans Ns... (Tentative X/Y)"), abandon
  au-dela de reconnect_max_attempts (0 = illimite), compteur remis a zero des
  qu'une connexion reussit. Ne se declenche jamais sur une simple erreur
  d'authentification (seul un `disconnected` apres connexion reussie
  declenche une reconnexion, pas un `error` de connexion initiale - sinon un
  mot de passe errone entrainerait des tentatives repetees).

- BaseTerminalTab : les signaux connected/disconnected/error d'une connexion
  sont desormais associes a la generation de connexion en cours au moment ou
  ils sont cables ; un signal parvenant apres qu'une reconnexion (manuelle ou
  automatique) ait deja demarre une nouvelle connexion est ignore. Necessaire
  ici : les signaux Qt entre threads sont mis en file et ne sont livres
  qu'apres le retour de reconnect() a la boucle d'evenements, alors qu'une
  nouvelle connexion est deja en place.

- SessionDialog : sections "Keepalive" et "Reconnexion automatique".

tests/test_keepalive.py (paramiko entierement mocke), test_reconnect_backoff.py
(fonction pure) et test_auto_reconnect.py (SSHConnection.start() neutralisee :
la programmation de la reconnexion ne depend d'aucune connexion reseau reelle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:27:19 +02:00
Florian
fe938a47b0 feat: ajouter le forwarding dynamique SOCKS (-D)
Les redirections -L et -R existaient deja ; il manquait -D, ou le port local
ouvert se comporte comme un proxy SOCKS dont la destination est negociee par
chaque client (navigateur, curl...) plutot que fixee a l'avance.

- app/tunnels.py : implementation minimale d'un serveur SOCKS4/4a/5 (CONNECT
  uniquement, sans authentification - largement suffisant pour un usage
  local/personnel). Reutilise le meme _ForwardServer et le meme _relay() que
  -L/-R ; seul le handshake initial differe (negocie la destination au lieu
  de la recevoir en config). DIRECTION_DYNAMIC s'integre dans describe() et
  Tunnel.stop() sans code specifique supplementaire.
- TunnelDialog : nouvelle option "Dynamique - SOCKS (-D)" ; les champs
  Machine/Port de destination se masquent automatiquement pour ce mode
  (QFormLayout.setRowVisible), puisque la destination n'est pas fixee ici.

tests/test_socks_tunnel.py : verifie SOCKS5 (adresse IPv4) et SOCKS4a (nom de
domaine) avec un vrai client SOCKS et un transport SSH mocke - a d'ailleurs
revele un bug reel (decode("idna", errors="replace") leve une exception, le
codec idna ne supportant pas errors="replace") sur le chemin nom-de-domaine,
corrige ici (decode("ascii", errors="replace"), les noms d'hote SOCKS etant
de l'ASCII simple).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:08:02 +02:00
Florian
c2bde19f59 feat: ajouter le support ProxyJump / bastion (jump host)
Une session peut desormais passer par un jump host avant d'atteindre sa
cible, avec 3 sources possibles (limite a un seul saut pour l'instant) :
une session PySSH existante, un Host de ~/.ssh/config, ou une configuration
manuelle (hote/port/utilisateur/authentification/cle).

- session_manager.py : BastionConfig (mode + parametres), champ
  Session.bastion. Jamais de mot de passe stocke : un bastion en mode "mot
  de passe" le redemande a la connexion, comme le fait deja Session.auth_type.
- app/bastion.py : resolve_bastion_target() convertit un BastionConfig en
  parametres de connexion concrets quelle que soit sa source, et leve une
  erreur claire si la reference est cassee (session supprimee, alias OpenSSH
  introuvable, config manuelle incomplete) plutot que d'echouer plus tard
  avec un message obscur.
- ssh_connection.py : la logique de connexion (auth, cle d'hote modifiee,
  mot de passe de secours en mode auto) est factorisee dans _connect_host(),
  reutilisee a l'identique pour le bastion et pour la cible.
  _open_bastion_channel() connecte d'abord le bastion puis ouvre un canal
  direct-tcpip vers la cible, passe en sock= a la connexion finale (mecanisme
  ProxyJump standard de paramiko). Le bastion a son propre SSHClient, ferme
  proprement a la fin de la connexion.
- SessionDialog : section "Bastion / Jump Host" (choix de la source + champs
  associes, caches/affiches via un QStackedWidget).
- ConnectionController resout le bastion et demande son mot de passe (si
  nécessaire) avant d'ouvrir l'onglet ; erreur affichee immediatement si la
  reference est invalide plutot que de lancer une connexion vouee a l'echec.
- app/openssh/config.py : un Host avec ProxyJump produit maintenant une
  Session dont le bastion reference cet alias (mode BASTION_OPENSSH) : le
  scenario proxmox-prod / bastion de la Phase 2 fonctionne de bout en bout.

tests/test_bastion.py (resolution des 3 sources + erreurs, et ouverture du
canal via un bastion entierement mocke, sans reseau reel) et
tests/test_session_dialog_bastion.py (UI : construction, pre-remplissage a
l'edition, resultat par mode).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:02:25 +02:00
Florian
c08037a638 feat: ajouter la prise en charge de ~/.ssh/config
Support minimum : Host, HostName, User, Port, IdentityFile, ProxyJump,
ForwardAgent, ServerAliveInterval, ServerAliveCountMax.

- app/openssh/config.py : load_openssh_hosts() lit ~/.ssh/config via
  paramiko.SSHConfig (gere nativement les blocs "Host *" fusionnes, les alias
  multiples sur une meme ligne Host, et l'expansion ~ des IdentityFile).
  Fichier absent = liste vide, pas une erreur. session_from_openssh_host()
  convertit un Host en Session PySSH (groupe "OpenSSH", cle si IdentityFile
  presente sinon authentification auto).
- Nouvelle section "Configuration OpenSSH" dans le panneau lateral
  (SessionController), sous Sessions SSH : double-clic ou menu contextuel pour
  se connecter directement ou importer comme session (editeur pre-rempli,
  ajustable avant enregistrement) ; rafraichissement via le menu ⚙ ou le menu
  contextuel de l'hote.
- ConnectionController.open_openssh_host() ouvre une connexion directe sans
  enregistrer de session, comme une connexion rapide.

Le ProxyJump est detecte, affiche (badge "· via <bastion>", tooltip) et reporte
dans les notes d'une session importee, mais pas encore applique automatiquement
a la connexion : ce sera l'objet de la Phase 3 (Bastion / Jump Host), qui
prendra en charge toutes ses sources (OpenSSH, session PySSH, config manuelle)
via un mecanisme commun.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:49:59 +02:00
Florian
d212e6ab44 feat: proteger les cles privees via ACL NTFS (icacls) sous Windows
chmod(0o600) n'a aucun effet sur les permissions NTFS reelles : sous Windows,
une cle privee generee restait accessible a tout compte ayant acces au
dossier (permissions heritees usuelles : Users/Authenticated Users).

app/security/key_protection.py : protect_private_key() verrouille l'ACL du
fichier via icacls (/inheritance:r puis /grant:r <utilisateur>:F), plutot que
d'ajouter une dependance pywin32 - plus lourde a empaqueter avec PyInstaller
pour un simple changement de permissions. Repli chmod(0o600) sous Linux/macOS.
Echec silencieux (icacls absent, systeme de fichiers sans ACL...), comme le
faisait deja l'appel chmod qu'elle remplace.

Branchee dans KeyManager.generate_key() (cf commit suivant). Non appliquee a
import_key() : une cle importee pointe potentiellement vers un chemin hors du
controle de l'application (~/.ssh partage avec d'autres outils), et la
consigne est de ne jamais rendre une cle moins accessible qu'elle ne l'etait -
ni, par prudence, de modifier sans le demander des permissions definies par
l'utilisateur lui-meme.

Verifie manuellement avec icacls apres generation reelle : seul le compte
Windows courant apparait dans l'ACL du fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:43:12 +02:00
Florian
23f5aaccd1 feat: sauvegarde atomique des JSON avec schema_version et recuperation .bak
sessions.json/keys.json/settings.json etaient ecrits directement
(Path.write_text) : un crash ou une coupure de courant pendant l'ecriture
pouvait laisser un fichier tronque, sans aucun recours.

- app/storage/atomic_file.py : ecriture .tmp + flush + fsync +
  os.replace() (atomique y compris sous Windows).
- app/storage/json_store.py : load_json_document()/save_json_document().
  Chaque sauvegarde conserve la version precedente dans un .bak ; au
  chargement, un fichier principal corrompu retombe automatiquement sur ce
  .bak avant de se rabattre sur des valeurs par defaut. Chaque document porte
  un schema_version, avec une chaine de migrations (vide pour l'instant) pour
  faire evoluer le format plus tard sans invalider les fichiers existants.
- SessionStore et KeyManager utilisent ce module (schema_version=1) ; un
  fichier legacy sans schema_version reste charge normalement et n'est reecrit
  qu'a la prochaine sauvegarde naturelle (comportement de "premiere ecriture"
  inchange par rapport a avant).
- settings.json (theme.py) et known_hosts (known_hosts_manager.py, via
  paramiko.HostKeys.save() redirige vers un .tmp puis os.replace())
  beneficient aussi de l'ecriture atomique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:42:59 +02:00
Florian
fd848c2025 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>
2026-09-05 01:42:46 +02:00
snoopy
f641357073 Copier-coller a la souris comme dans un terminal Unix
Selectionner copie aussitot au relachement du bouton, et le clic droit colle
directement. Plus de menu contextuel ni de raccourci a connaitre : c'est le
comportement des terminaux Unix et de PuTTY.

- selection relachee, et double-clic sur un mot, copient dans la foulee
- clic droit et clic molette collent immediatement
- un simple clic sans deplacement se contente d'annuler la selection, sans
  toucher au presse-papiers : sinon il ecraserait ce qu'on s'apprete a coller
- le menu contextuel est supprime, le clic droit etant desormais un collage
- "Tout selectionner" passe sur Ctrl+Maj+A, Ctrl+A restant "debut de ligne"
  pour le shell

Les raccourcis clavier restent disponibles pour qui les prefere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:30:17 +02:00
snoopy
1f9aedfdb2 Panneau lateral masquable et redimensionnable, plus de types de cles
Le panneau des sessions etait declare Movable|Floatable mais pas Closable : il
n'avait donc aucun bouton de fermeture et ne pouvait pas etre masque. Sa largeur
plancher etait par ailleurs imposee par son contenu, ce qui empechait de le
retrecir vraiment.
- ajout de DockWidgetClosable, largeur minimale ramenee a 120 px
- bascule par F9, par le menu Affichage, ou par la croix du panneau, les trois
  restant synchronises
- separateur elargi a 6 px et surligne au survol, pour etre attrape facilement

Generation de cles : ajout d'ECDSA sur les courbes NIST P-256, P-384 et P-521,
en plus d'Ed25519 et RSA. Ed25519 reste le choix par defaut : c'est aussi une
courbe elliptique (Curve25519), plus courte et plus rapide, et sans les
interrogations qui entourent le choix des parametres des courbes NIST. Chaque
type est verifie relisible par paramiko, donc utilisable pour se connecter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:08:28 +02:00
snoopy
9d346d76ed Tunnels SSH, envoi multi-onglets, et reparation de main_window.py
Tunnels (redirection de ports), la specialite de Bitvise : un bouton dans la
barre d'un onglet SSH ouvre un gestionnaire de redirections.
- locale (-L) : un port de votre poste est relaye, via la connexion SSH, vers
  une machine joignable depuis le serveur (typiquement un service qui n'ecoute
  que sur la boucle locale du serveur)
- distante (-R) : l'inverse
Chaque tunnel tourne dans son propre thread, un relais bloque ne genant ni
l'interface ni les autres tunnels. Ils sont fermes avec la session.

Envoi simultane, façon MobaXterm : le bouton "Multi" diffuse la frappe vers
tous les onglets terminal. La diffusion ecrit directement dans les connexions
et non via les widgets terminal, sinon chaque onglet rediffuserait a son tour.

Reparation : un de mes scripts de correction avait reecrit main_window.py en
retraduisant deux fois les fins de ligne, produisant des sequences \r\r\n ;
le fichier avait ete committe ainsi (2538 lignes au lieu de 856, une ligne vide
inseree partout). Le contenu etait intact, seule la mise en forme etait touchee.
Le fichier est reconstitue par normalisation des fins de ligne, et un controle
d'integrite a ete ajoute a la verification de non-regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:52:25 +02:00
snoopy
f537868da3 Confort du terminal, reconnexion, et hotes deja connus de ssh
Zoom de police : Ctrl+molette et Ctrl+ +/-, Ctrl+0 pour revenir a la taille
par defaut. La taille choisie est memorisee entre deux lancements.

Barre de defilement de l'historique. pyte ne sait defiler que par demi-pages :
la barre est donc graduee en pages plutot qu'en lignes, ce qui donne un repere
visuel de position et permet de faire glisser. Maj+PagePrec/Suiv au clavier,
Ctrl+Fin pour revenir a la vue en direct.

Recherche dans l'historique (Ctrl+F) : la barre apparait en bas de l'onglet et
la recherche remonte l'historique page par page jusqu'a trouver, en
selectionnant la correspondance — donc visible et directement copiable.

Reconnexion en un clic : un bouton apparait dans la barre de l'onglet des que
la connexion tombe. La fabrication de la connexion a ete extraite dans
_create_connection(), ce qui permet de la recreer a l'identique sans ressaisir
quoi que ce soit. Utile avec des serveurs de test qui redemarrent souvent.

Les hotes deja approuves par la commande ssh (~/.ssh/known_hosts) sont
desormais reconnus, ce qui evite de reconfirmer une empreinte que votre ssh
connait deja. paramiko ne reecrit jamais ce fichier : les nouvelles cles
continuent d'aller dans celui de l'application.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:31:50 +02:00
snoopy
f82288b2e4 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>
2026-09-04 11:24:55 +02:00