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>
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>
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>
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>
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>
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>
- 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>
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>
main_window.py concentrait la fenetre principale, l'arbre des sessions, les 4
classes d'onglets, la detection de commandes ssh et le multi-send : 983 lignes
et une seule classe qui pilotait toute l'application.
- app/services/ssh_target.py : detection d'une cible ssh dans un terminal (pur,
sans dependance Qt)
- app/ui/tabs.py : BaseTerminalTab / SessionTabWidget / SFTPTabWidget /
LocalTerminalTabWidget (deplaces tels quels)
- app/controllers/session_controller.py : panneau lateral (arbre sessions +
terminaux locaux) et CRUD des sessions
- app/controllers/connection_controller.py : cycle de vie des onglets
(ouverture, connexion rapide, renommage, fermeture)
- app/controllers/broadcast_manager.py : bouton et logique du mode Multi-Send
main_window.py ne fait plus que construire ces composants et les relier par
signaux (983 -> ~200 lignes). Comportement inchange ; MainWindow.tabs et
detect_ssh_target restent importables depuis app.main_window pour la
compatibilite avec les tests existants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>