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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>