Contexte & invariants

Dernière revue : 2026-07-31

Faits stables sur l'architecture de ce plugin et pièges déjà résolus, groupés par catégorie — l'utilité de ce fichier est d'éviter de refaire un piège, donc de retrouver d'un coup d'œil tout ce qui concerne un domaine (CSS, API Tabby, config.yaml...) plutôt que de parcourir une liste chronologique. La numérotation (#N) reste stable et sert d'identifiant référencé ailleurs (CLAUDE.md, ROADMAP.html) — elle ne reflète donc plus l'ordre d'apparition ci-dessous. Les commandes de build/dev restent dans CLAUDE.md à la racine du dépôt — ce fichier ne les duplique pas.

Prochain numéro libre : #79. Qui ajoute un piège prend ce numéro et incrémente cette ligne dans le même geste. Le #7 est un trou hérité de la restructuration doc ; un numéro consommé ne se réattribue jamais, même si son entrée disparaît.

Légende

Un piège marqué ⚠️ à revérifier après update Tabby fait aussi partie de la checklist à rejouer après toute montée de version de Tabby (ancien "points fragiles") — ce ne sont pas des bugs, juste des points qui reposent sur un comportement non contractuel de Tabby.

Sommaire

Identité du projet

Environnement & build

Avant même de faire tourner ou de publier le plugin.

Version de Tabby de référence : 1.0.235 (au 2026-08-08)

C'est la version installée sur le poste de développement et celle contre laquelle tout ce document a été vérifié — y compris les pièges et la checklist de montée de version ci-dessous. Le tabby-core qu'elle embarque s'annonce, lui, en 1.0.231-nightly.0 : c'est ce numéro-là, et non celui de l'application, que cible la peer dependency du package.json — les deux ne coïncident pas, et confondre l'un avec l'autre fait chercher une incompatibilité qui n'existe pas. À mettre à jour ici et dans le README quand la référence change.

#1 — TABBY_PLUGINS cassé sur Windows

Tabby fait process.env.TABBY_PLUGINS.split(':') ; un chemin Windows (C:\...) contient un : après la lettre de lecteur → chemin tronqué, plugin jamais trouvé, sans erreur visible. Bug Tabby, pas du plugin. Solution retenue : ne pas utiliser cette variable. Jonction NTFS directe :

New-Item -ItemType Junction -Path "$env:APPDATA\tabby\plugins\node_modules\tabby-better-sidebar" -Target "C:\Users\Alex Ramirez\Documents\Développement\tabby-ssh-sidebar"
Piège opérationnel

Désactiver le plugin depuis l'UI Extensions de Tabby supprime la jonction (pas juste un flag) — la recréer manuellement pour revenir en mode dev.

#2 — package.json sans champ author

Le loader de plugins fait author.name || author sans vérifier l'existence — plante silencieusement, logué juste comme "Cannot load package info for X". Toujours mettre un champ author, même vide.

#4 — Versions verrouillées pour Angular 15 (peer dep de tabby-core)

#5 — npm install échoue sur un postinstall qui compile du natif

Constat d'origine sur tabby-ssh : son postinstall tentait de compiler des bindings natifs inutilisés (on ne consomme que ses types). → npm install --ignore-scripts.

La cause d'origine a disparu, le remède reste — revu le 2026-07-31

tabby-ssh n'est plus une dépendance de ce projet depuis le correctif du piège #34 : ses typings sont vendorisés dans src/types/tabby-ssh/. Le flag garde pourtant sa raison d'être — l'arbre installé contient toujours @parcel/watcher, dont le script d'installation (build-from-source.js) compile depuis les sources. Vérifier avant de retirer le flag, pas l'inverse : un npm install qui échoue à mi-chemin laisse un node_modules partiel.

Composants Angular dans un plugin tiers

Patterns spécifiques au chargement d'un plugin après le bootstrap principal de l'appli — un composant Angular classique n'a pas ces contraintes.

#3 — templateUrl/styleUrls ne marchent pas pour un plugin tiers

Résolus uniquement au bootstrap principal de l'appli. Pattern correct (voir tabby-clippy) :

@Component({ template: require('./x.pug') })  // pas templateUrl

SCSS : import en side-effect (import './x.scss') + style-loader/ css-loader/sass-loader en webpack (injection CSS globale). Nécessite apply-loader devant pug-loader pour que require('./x.pug') retourne une string.

#14 — :host ne fonctionne pas dans le SCSS de ce plugin

Le SCSS est injecté en CSS globale brute (side-effect import, voir piège #3) — :host n'est interprété que par le compilateur de templates Angular ou en Shadow DOM natif, ni l'un ni l'autre ne s'applique ici. Le navigateur ignore silencieusement la règle (aucune erreur). → Cibler le nom du tag custom element réel (ex. sidebar-plus-tree { ... }) à la place.

#56 — Réutiliser une classe stylée par un autre composant ne réutilise rien si son sélecteur est descendant de ce composant

Le bloc « Transferts » reprenait les classes .active-sessions-header et .active-sessions-count du bloc « Sessions actives », en supposant hériter de son apparence. Mais ces règles sont écrites sous sidebar-plus-tree .active-sessions { ... }, et le bloc des transferts est un composant frère : aucune ne s'est jamais appliquée.

Le piège n'est pas la règle CSS, qui est ordinaire — c'est qu'il n'y a aucun symptôme franc. L'en-tête s'affichait, simplement sans display: flex : le bouton passait à la ligne suivante (un div redevenu bloc) et me-auto, qui n'a d'effet que dans un conteneur flex, laissait le compteur collé au titre — « Transferts2 ». Deux défauts d'apparence sans rapport apparent, une seule cause. Un commentaire en tête du fichier affirmait par-dessus le marché que le style était partagé, ce qui a fait chercher ailleurs.

Vérifier, pas supposer

Devant un style « qui ne prend pas », lire le sélecteur complet de la règle visée avant de toucher aux propriétés — et ne jamais croire un commentaire qui dit qu'un style est partagé sans l'avoir constaté dans le DOM (getComputedStyle via CDP tranche en une commande).

#33 — Un composant Tabby greffé ailleurs arrive sans mise en page : le CSS natif est encapsulé, contrairement à celui de ce plugin

Asymétrie facile à prendre à l'envers. Ce plugin injecte son SCSS en CSS globale brute (piège #3/#14), mais Tabby passe le sien par styles: [...] avec l'encapsulation de vue Angular par défaut : ses règles sont réécrites avec des attributs [_ngcontent-…]/[_nghost-…]. Vérifié le 2026-07-29 dans le décorateur compilé de SFTPPanelComponent et de SSHTabComponent.

Conséquence en montant SFTPPanelComponent dans la sidebar :

→ Fournir soi-même le dimensionnement, et poser min-height: 0 sur chaque maillon de la chaîne flex qui descend jusqu'au conteneur scrollable du composant : la valeur par défaut min-height: auto d'un élément flex refuse de rétrécir sous son contenu, ce qui transforme silencieusement un descendant overflow-y: auto en contenu rogné. Les règles globales de ce plugin peuvent bien ajouter des propriétés à l'intérieur d'un composant natif (l'encapsulation ne protège de rien vers l'extérieur), mais elles perdent sur toute propriété que sa propre feuille définit : :host > .header compte deux sélecteurs d'attribut, plus spécifique que n'importe quelle chaîne de sélecteurs de tag.

Piège voisin, sur [hidden] : cet attribut ne vaut que par une règle de la feuille de style user-agent, et n'importe quelle règle display de niveau auteur la bat quelle que soit sa spécificité. Un tag { display: flex } écrit ici rend donc [hidden] inopérant sur ce tag — il faut ajouter explicitement tag[hidden] { display: none }.

#15 — @HostListener('document:click') ignore stopPropagation()

Se déclenche même quand un descendant a appelé $event.stopPropagation() sur un (click) normal. → Ne jamais compter sur la propagation ; vérifier explicitement (event.target as HTMLElement).closest('.mon-popup, ...') dans le handler lui-même.

#18 — Handler (contextmenu) sur l'espace vide de la sidebar

Doit être posé sur l'élément qui remplit réellement tout l'espace visible (.sidebar-plus-tree.h-100), pas sur celui qui ne fait que la hauteur de son contenu (.sidebar-plus-tree-container) — sinon un clic droit sous le dernier groupe ne déclenche rien pour un vrai clic utilisateur (un clic synthétique de test peut donner une fausse impression que ça marche).

#19 — cdkDropList vide = 0px de hauteur

Cible de drop quasi impossible à atteindre. Devenu un bug de production réel avec "Nouveau dossier..." (toujours vide à la création) : le drop échoue silencieusement, aucune erreur, aucun changement dans config.yaml. → premier correctif : min-height: 8px sur tous les div[id^='profiles-'] et div[id^='groups-'].

Correctif remplacé depuis — revu le 2026-07-31

Le min-height: 8px inconditionnel n'existe plus dans le code : il ajoutait de l'espace mort sous chaque dossier. La chaîne #19 → #24 → #25 → #26 a abouti à un correctif différent, appliqué seulement aux listes :emptymin-height: 16px + margin-top: -16px + pointer-events: none (sidebarTree.component.scss). Le piège lui-même reste vrai, c'est sa ligne de correctif qui était périmée : voir #26 pour l'état en vigueur.

#21 — Une mauvaise indentation pug déplace un élément vers le mauvais parent, sans erreur

Pug est sensible à l'indentation pour déterminer la hiérarchie DOM : une ligne indentée d'un cran de trop ou de trop peu ne casse pas la compilation, elle change silencieusement de parent. Cas réel : un badge .favorite-badge ajouté après span.ms-2.me-auto (enfant de a.tree-item, un conteneur flex) mais indenté au même niveau que a.tree-item lui-même → devient un sibling de l'élément flex au lieu d'un enfant, se retrouve hors du flux flex, s'affiche comme un bloc séparé sous la ligne et décale tout ce qui suit dans l'arbre. Aucune erreur de build, aucun warning — repéré seulement visuellement par l'utilisateur en testant. → Après tout ajout/déplacement de ligne dans un .pug, vérifier que son indentation correspond exactement à celle du sibling voisin visé (compter les espaces, pas "à l'œil"), surtout via l'outil Edit où l'indentation de new_string est tapée à la main plutôt que copiée du contexte environnant.

#22 — Un cdkDropList derrière *ngIf disparaît complètement de l'enregistrement CDK, pas juste visuellement

Les zones de dépôt d'un dossier (profiles-<id>/groups-<id>) étaient enveloppées dans ng-container(*ngIf='!group.collapsed') avec les lignes de contenu — logique en apparence (pas la peine d'un drop-zone pour un contenu invisible), mais *ngIf détruit l'instance de la directive cdkDropList, qui n'est donc plus dans le registre interne de CDK. Un cdkDropListConnectedTo référençant cet id devient alors une connexion vers une liste inexistante : glisser un dossier vers un dossier replié n'affichait aucune zone de dépôt du tout, sans erreur (bug réel signalé le 28/07). → Garder le div(cdkDropList, ...) toujours monté (bénéficie déjà du min-height: 8px du piège #19, donc reste une cible atteignable même vide/repliée) et ne mettre *ngIf QUE sur le contenu à l'intérieur (les lignes de profils/sous-dossiers), jamais sur le conteneur cdkDropList lui-même.

#30 — Depuis ngAfterViewChecked(), le DOM contient encore l'élément sortant quand deux *ngIf frères basculent dans le même cycle

Symptôme observé le 29/07 : les menus ouverts au clic droit étaient bien recadrés dans l'écran, mais pas les popups ouvertes depuis un item de menu (nouveau dossier, renommer, confirmation de suppression, sélecteur d'icônes), qui débordaient en bas.

Le recadrage (clampContextMenuPosition()) mesure la popup via un document.querySelector sur les trois classes flottantes. Or quand un item de menu fait passer contextMenuMode de 'menu' à 'createGroup', les deux branches *ngIf basculent dans le même cycle de détection, et le DOM lu depuis ngAfterViewChecked() contient encore l'élément sortant. Mesuré sur le bug réel : mode=createGroup elt=group-context-menu h=74 alors que la popup effectivement rendue faisait 109px. Le calcul portait donc sur l'ancienne hauteur et concluait « rien à corriger » (1104 − 74 − 4 = 1026, exactement la position courante) — d'où un débordement de 35px, soit précisément l'écart de hauteur. L'ouverture au clic droit y échappait pour une raison sans rapport avec le geste : la popup précédente avait été détruite lors d'un cycle antérieur, donc le DOM était déjà cohérent.

→ Différer la mesure d'un tour de boucle d'événements (setTimeout, patché par Zone.js donc exécuté dans Angular et suivi d'un cycle qui reprend les valeurs corrigées). Pas de boucle : menuPositionDirty est déjà remis à false et le recadrage ne le repositionne jamais.

Piège de diagnostic associé : une explication plausible mais fausse a d'abord été retenue — la séquence d'événements (contextmenu suivi d'un mouseup qui relançait un cycle, contre click dernier de sa séquence). Elle prédisait le bon symptôme et a coûté un cycle de correction inutile. Ce qui l'a tranchée est une trace temporaire imprimant l'élément réellement mesuré et sa hauteur : sur un bug de géométrie, mesurer ce que le code voit avant de raisonner sur ce qu'il devrait voir.

Reste vrai et conservé : écrire aussi la position directement sur l'élément (menu.style.left/top) en plus des champs liés, car ngAfterViewChecked() s'exécute après le rendu des liaisons du cycle. Invariant à préserver : toute popup positionnée via contextMenuX/Y passe par cet unique point de recadrage — ne pas positionner une nouvelle popup autrement.

#39 — attr.x='...' en pug sans crochets n'est pas un binding Angular : l'attribut s'appelle littéralement attr.x

Le binding d'attribut Angular s'écrit [attr.x]='expr'. Écrit attr.x='', pug produit un attribut HTML statique dont le nom est attr.x — vérifié dans le template compilé : attr.data-profile-row="" au lieu de data-profile-row="". Aucune erreur de compilation, aucun avertissement Angular : la balise porte simplement un attribut au nom inattendu, et tout querySelector/closest('[data-profile-row]') qui le cherche échoue silencieusement pour toujours.

Cas réel (2026-07-29, sélection multiple) : le marqueur servait au @HostListener('document:click') à reconnaître une ligne de profil. Ne le reconnaissant jamais, le handler vidait la sélection sur le clic même qui venait de la créer — symptôme observé : « cocher ne fait rien », alors que le clic fonctionnait parfaitement et était défait dans le même tick. À ne pas confondre avec le piège #15 : là c'est le principe du closest() qui manquait, ici le closest() était bien là mais visait un attribut inexistant.

→ Pour un attribut purement statique servant de marqueur, l'écrire en HTML brut (data-profile-row='1') ; pour une valeur liée, [attr.data-x]='expr'. Et vérifier dans dist/index.js que l'attribut sort sous le nom attendu — le template pug compilé y est une simple chaîne, un grep suffit.

#41 — la reprise après await config.save() ne s'exécute pas dans la zone Angular : l'état change, la vue ne se repeint pas

Constaté le 2026-07-29 sur la confirmation transitoire de la barre de sélection : le champ était correctement affecté (vérifiable au débogueur) mais rien ne s'affichait jamais, et le setTimeout d'expiration le remettait à null trois secondes plus tard sans que l'utilisateur ait rien vu. Le config.save() de Tabby passe par de l'I/O Electron, et la continuation d'un await posé dessus ne revient pas nécessairement dans la zone Angular — donc aucune détection de changement n'est déclenchée. Un setTimeout créé depuis cette continuation n'est pas davantage patché par Zone.js, ce qui prive aussi l'effacement de son repaint.

Ce qui rend le diagnostic trompeur : notifications.notice() de Tabby, appelé au même endroit, s'affichait normalement (il a son propre mécanisme de rendu). La panne paraît donc sélective — « la notification marche, pas mon bandeau » — et oriente à tort vers le template.

→ Envelopper dans this.zone.run(() => { ... }) (injecter NgZone) toute mutation d'état destinée à l'affichage effectuée après un await sur une API Tabby. Bonus : les setTimeout créés à l'intérieur redeviennent patchés, donc leur expiration repeint toute seule.

#69 — Réécrire un title détruit l'infobulle native affichée : une valeur qui se rafraîchit scintille sous le curseur

Constaté le 2026-08-03 sur l'infobulle des sessions actives, qui porte une latence et une durée. Le navigateur ne met pas à jour une infobulle ouverte : il la ferme et la rouvre dès que l'attribut change. Une valeur liée à un état rafraîchi périodiquement — ici le sondage de 2 s de la sidebar, dont les secondes bougent à chaque passage — clignote donc précisément pendant qu'on essaie de la lire. Aucune erreur, et le symptôme ne ressemble pas à sa cause : on cherche du côté du rendu de la ligne, alors que le coupable est la fraîcheur de la donnée.

Figer la valeur tant que le pointeur est sur l'élément (mouseenter/mouseleave + champ mémorisé), et la laisser se rafraîchir en dehors : hors survol, aucune infobulle n'est à l'écran, donc rien ne peut scintiller. Angular ne touche au DOM que si la valeur change, donc rendre la même chaîne suffit — nul besoin de détacher quoi que ce soit de la détection de changement.

Le prix à connaître : l'infobulle montre l'instant où le pointeur est arrivé. Sortir et revenir la rafraîchit, ce qui est le geste naturel pour relire une bulle. La même précaution vaudra pour toute infobulle branchée sur une donnée vivante — transfert en cours, compteur, état de session.

#52 — Un service providedIn: 'root' que personne n'injecte n'est jamais construit

Angular instancie à la demande. Un service dont tout le travail se fait dans le constructeur ne s'exécute donc que si quelqu'un le réclame. Constaté sur le nettoyage des fichiers temporaires : il n'était atteint que par le panneau SFTP, si bien que sa purge de démarrage tournait à l'ouverture du panneau — c'est-à-dire jamais pour une session qui laisse justement des copies derrière elle.

→ Injecter ces services dans le constructeur du NgModule (src/index.ts), comme SidebarPlusMountService. Le symptôme est trompeur : aucune erreur, le service existe, il ne s'est simplement jamais construit.

#54 — Un appel de méthode dans un template est réévalué à chaque cycle, et Tabby en déclenche beaucoup

Un terminal vivant suffit à provoquer des cycles de détection en continu. Chaque {{ f(x) }} ou [attr]='f(x)' y est réexécuté. Sur la liste SFTP d'un dossier très peuplé, l'icône, l'infobulle et chaque cellule étant des appels, cela faisait des milliers de formatages de date et de taille par cycle — panneau inutilisable, sans qu'aucun profil ne pointe un coupable évident.

Le pire n'est pas le calcul, c'est l'identité

Un getter qui renvoie un tableau neuf (ici la liste des colonnes visibles, alimentant un *ngFor à l'intérieur de chaque ligne) force Angular à rediffer la boucle de toutes les lignes à chaque cycle. Le coût est sans rapport avec celui du filter lui-même.

→ Précalculer une vue de ligne, mise en cache et invalidée par référence sur ses sources ; ne laisser en appel que ce qui change indépendamment de la liste et se résout en une consultation de Set/Map. Et pour un rafraîchissement périodique, fusionner le nouveau listing dans l'ancien en conservant l'objet des entrées inchangées : sans réaffectation, les caches restent valides et le cycle ne rend rien du tout.

#42 — Le clic droit « Supprimer » hérité du panneau SFTP natif confirme via un dialogue OS, pas une modale HTML

Découvert le 2026-07-30 en creusant la demande utilisateur « toutes les confirmations en modale HTML, jamais un appel système ». SidebarPlusSftpBrowserComponent sous-classe SFTPPanelComponent et hérite tel quel de son showContextMenu(), qui appelle platform.popupContextMenu() (menu natif Electron) construit à partir des SFTPContextMenuItemProvider enregistrés — dont l'unique fourni nativement, CommonSFTPContextMenu (tabby-ssh/src/sftpContextMenu.ts), dont l'entrée « Delete » confirme via platform.showMessageBox(), un vrai dialogue OS, avant d'ouvrir SFTPDeleteModalComponent (lui, HTML/NgbModal, mais purement un modal de progression de la suppression récursive — pas la confirmation elle-même).

Un menu contextuel sous-classé n'est donc pas automatiquement conforme aux invariants de ce plugin simplement parce que le composant qui l'affiche l'est : chaque entrée héritée porte son propre code, à vérifier au même titre qu'une classe Tabby avant de la considérer acquise. SFTPDeleteModalComponent a par ailleurs été vérifié absent du bloc d'export runtime de tabby-ssh (piège #13) — @hidden l'est vraiment ici, contrairement au cas de EditProfileModalComponent cité au piège #17 — donc pas réutilisable tel quel malgré sa présence dans les typings vendorisés ; sa logique de récursion (readdir puis rmdir/unlink) a été reprise en petit, pas le composant.

showContextMenu() est surchargé dans SidebarPlusSftpBrowserComponent : l'entrée « Delete »/« Supprimer » du menu construit par buildContextMenu() (hérité) est retirée — CommonSFTPContextMenu étant l'unique provider natif, elle est toujours la dernière de la liste, avec un contrôle sur son libellé en filet de sécurité — puis remplacée par une entrée maison ouvrant ConfirmModalComponent (HTML/NgbModal maison) avant de supprimer.

#71 — Une classe posée par CDK n'est pas forcément hors de portée : vérifier qui la porte avant de renoncer à la cadrer

Le SCSS de ce plugin est de la CSS globale (#14), et Tabby utilise le même @angular/cdk/drag-drop que nous — sa barre d'onglets comprise. Une règle écrite sur une classe cdk-* nue s'applique donc aussi à l'application hôte. Les trois classes en jeu ne se traitent pas de la même façon, et le raccourci « CDK déplace tout dans un calque, donc rien n'est cadrable » est faux :

La méthode, plus que le verdict : chacun de ces trois points a été lu dans la source de @angular/cdk installée, pas déduit du comportement d'un voisin. Un commentaire de notre propre code affirmait le contraire pour le placeholder et se recopiait de proche en proche. Et pour savoir si une classe déborde, la confronter aux builtin-plugins/ de l'app installée : c'est ainsi que .grabber a été trouvée dans la sidebar native, et que les autres classes du fichier ont pu être laissées telles quelles en connaissance de cause.

Vérifier une classe/API Tabby avant de s'y fier

La plus grosse source de pièges de ce projet : ce plugin s'appuie par endroits sur des classes/composants internes de Tabby non garantis par une API publique stable. La méthode de vérification ci-dessous (issue des pièges #6/#13) s'applique à toute nouvelle classe Tabby utilisée — d'où les points de veille listés en fin de section, à revérifier après chaque mise à jour de Tabby.

#6 — Typings npm de tabby-core en retard sur l'app installée

buildGroupTree(), icon/color/parentGroupId sur ProfileGroup existent à l'exécution (1.0.235) mais pas dans les types publiés (tag nightly, plus vieux). → Augmentation de type dédiée (src/tabby-core-augment.d.ts) plutôt que des any disséminés.

Deuxième cas rencontré le 2026-07-29, en dérivant FileDownload/FileUpload pour l'édition distante : les typings npm déclarent getMode() abstraite sur FileTransfer — donc aussi sur les téléchargements — et ignorent setTotalSize()/setStatus()/getTotalSize(), que la classe installée possède ; l'installée, elle, ne met getMode() que sur FileUpload. Ici l'augmentation n'aide pas (on ne peut pas retirer un membre abstrait). → Écrire l'union : implémenter tout ce que les deux exigent et n'appeler aucune méthode absente de l'ancienne. Les aides de progression sont sans enjeu dans ce cas précis, ces transferts n'étant rattachés à aucune UI de Tabby.

#48 — Les paramètres qui suppriment le sélecteur de fichier de startDownload()/startUpload() n'existent pas dans les typings npm

Cas particulier du #6, assez coûteux pour mériter son numéro. Les typings npm déclarent startDownload(name, mode, size) et startUpload(options?) (tabby-core/typings/api/platform.d.ts). L'app installée, elle, expose startDownload(name, mode, size, filePath) et startUpload(options, paths) : chemin fourni ⇒ aucun dialogue n'est ouvert. C'est ainsi que la commande « Edit locally » native de tabby-electron travaille sur un fichier temporaire.

Piège

Lire ces signatures dans les typings mène à conclure que ces méthodes sont inutilisables sans interaction, donc à réimplémenter un transfert maison. Ce que ça coûte n'est pas le code écrit, mais ce qu'on perd : un transfert obtenu par startDownload() est publié sur fileTransferStarted$, auquel la tab-bar de Tabby est déjà abonnée — elle l'empile dans sa liste et ouvre son menu de transferts toute seule. Progression, file et annulation sont donc fournies. → Vérifier l'arité réelle dans le bundle installé (méthode du #13) avant de conclure qu'une API de Tabby impose un dialogue.

#49 — webContents.startDrag() exige un fichier déjà présent sur le disque, et le geste natif fige le renderer

Deux contraintes distinctes, qui imposent ensemble la conception en deux temps du glisser-déposer sortant :

#55 — Un dragstart laissé à Chromium réveille la zone de dépôt de Tabby, qui lève une exception dans le vide

Le glisser-déposer sortant par DownloadURL (voir le chantier) n'appelle délibérément pas preventDefault() : c'est le glisser natif du moteur qui fait le travail. Effet de bord — le glisser se propage à la fenêtre et réveille la dropZone.directive de tabby-core, celle qui accueille un fichier déposé depuis l'Explorateur vers un terminal. Elle crée son voile « déposez ici » sur dragenter et programme un setTimeout pour lui ajouter sa classe visible, mais le voile est retiré entre-temps : Cannot read properties of undefined (reading 'classList'), levée depuis tabby-core et non depuis ce plugin.

Sans conséquence observable, et laissé tel quel après constat : Tabby avale l'exception, le transfert n'est pas affecté, et le scintillement théorique du voile n'a été vu ni en test ni à l'usage. À connaître pour ne pas partir en chasse d'un bug du plugin en lisant la console pendant un glisser — le fichier fautif est celui de Tabby.

#64 — startUploadFromDragEvent() ne lit qu'une tranche de chaque dossier déposé : au-delà, le reste part à la poubelle sans un mot

PlatformService.startUploadFromDragEvent() parcourt l'arborescence déposée en appelant dirReader.readEntries(...) une seule fois par dossier. Or readEntries() ne rend pas un listing : il rend une tranche — 100 entrées sous Chromium — et le seul signal de fin prévu par l'API est une réponse vide, donc il faut le rappeler jusqu'à ce qu'il ne rende plus rien. Un dossier de 250 fichiers en envoie 100, sans erreur, sans avertissement, et le récapitulatif qui suit compte ce qu'il a vu : « 100 fichiers envoyés ».

Ce piège vaut pour tout ce qui accepte un dépôt, y compris la dropZone.directive de tabby-core, qui délègue à cette même méthode. → Ce plugin fait sa propre traversée (readAllEntries() dans sftpBrowser.component.ts), qui boucle jusqu'à la réponse vide. Deux effets de bord bienvenus : startUploadFromDragEvent() publie chaque fichier sur fileTransferStarted$ pendant le parcours — donc avant toute confirmation, ce qui laissait des lignes « en cours » que rien ne terminait si l'utilisateur renonçait — et appRoot y étant abonné, chaque dépôt ouvrait le menu de transferts natif. Construire nos HTMLFileUpload nous-mêmes et les inscrire au registre au moment où ils démarrent règle les deux.

HTMLFileUpload est exportable : présent dans les typings et dans le bloc d'export runtime du bundle installé, vérifié aux deux niveaux (#13).

#57 — SFTPPanelComponent.ngOnInit() appelle openSFTP() hors de son try/catch : l'échec tue le panneau en silence

Dans le composant natif :

this.sftp = await this.session.openSFTP()   // ← hors du try
try { await this.navigate(this.path) } catch { … }

Seul le navigate() est gardé. Un serveur sans sous-système SFTP, ou un transport mort entre l'ouverture de l'onglet et la construction du panneau, fait rejeter le premier appel : rejet de promesse non géré, this.sftp reste undefined, et le panneau n'affiche rien et ne dit rien. Toute sous-classe qui fait await super.ngOnInit() hérite du problème. → Entourer l'appel au parent d'un try/catch et en faire un état lisible, faute de quoi la seule issue visible pour l'utilisateur est de tuer l'onglet.

#58 — Un glisser-déposer sortant ne dit jamais où le fichier a été déposé, et l'affichage se termine avant l'écriture

Mesuré le 2026-08-02 sur un fichier de 5,52 Go. Notre ligne de transfert passe à « terminé » à 17:03:48 ; une seconde plus tard une fenêtre de l'Explorateur Windows apparaît (processus explorer, classe OperationStatusWindow, titre « X % terminé ») et ne disparaît qu'à 17:04:11. 22 secondes pendant lesquelles la sidebar annonçait une fin qui n'avait pas eu lieu.

Mécanique : Chromium matérialise le contenu dans un fichier temporaire, puis remet ce chemin au shell, qui copie vers l'endroit du dépôt (250 Mo/s mesurés — une copie, pas un renommage, donc volumes distincts). Cette fenêtre appartient à explorer.exe : elle n'est pas masquable depuis le plugin, et Tabby n'y est pour rien (aucun dialogue de ce genre dans son bundle).

Aucune API ne donne la destination — elle se retrouve, elle ne se demande pas

Le protocole de glisser-déposer de Windows ne communique jamais à l'application source l'endroit où la cible a déposé : will-download d'Electron ne rendrait que le chemin temporaire de Chromium. Ce contrôle est en revanche direct sur les téléchargements lancés par le menu contextuel, où c'est nous qui imposons le chemin à platform.startDownload() — ne pas confondre les deux.

Corrigé le 2026-08-03 : ce piège concluait qu'un contrôle « le fichier est-il bien arrivé ? » était impossible. C'est vrai de l'API, faux en pratique. Une offre DownloadURL peut servir un marqueur de 0 octet au nom unique au lieu de la charge utile : le shell l'écrit dans le dossier visé, et le chercher dans les emplacements plausibles (Bureau, Téléchargements, fenêtres de l'Explorateur ouvertes énumérées par Shell.Application, un niveau de sous-dossiers) fait apparaître la destination réelle. La requête HTTP du marqueur signale de surcroît que le dépôt a eu lieu. Heuristique et non garantie — elle échoue sur un dépôt hors de ces emplacements — mais elle transforme une impossibilité en repli. Relevé chez tabby-sftp-panel ; conception dans marqueur de dépôt (au registre du réalisé).

Ce qui en est fait dans ce plugin : un dossier passe par le marqueur, donc c'est nous qui écrivons à un chemin connu — sa fin est exacte. Un fichier reste servi en DownloadURL portant ses vrais octets, délibérément : le marqueur suppose que la destination est un dossier du système de fichiers, ce qu'une zone d'envoi de navigateur ou un client de messagerie n'est pas. Pour lui, l'écart mesuré ici est couvert par un état handover (« remise au système ») levé sur une durée estimée d'après la taille — approximation assumée, faute de tout signal de fin.

#67 — Une cible qui ne comprend pas une offre de fichier différé ne produit aucun signal

Mesuré le 2026-08-03 en déposant un dossier distant sur le Terminal Windows puis sur MobaXterm : rien ne se passe, et rien ne peut se passer. Ces applications acceptent du texte ou une liste de chemins réels, pas une offre DownloadURL, dont le contenu est produit à la demande. Elles ne réclament donc jamais l'URL — le serveur local ne reçoit aucune requête.

Conséquence à ne pas confondre avec une panne : sur cette voie, la requête HTTP est le seul signal qu'un dépôt a eu lieu. Pas de requête ⇒ pas de dépôt à traiter, donc pas de marqueur écrit, et le repli « destination introuvable » ne se déclenche pas — ce qui est le comportement voulu : une cible incompatible ne doit pas provoquer une livraison surprise dans le dossier de repli. Ce repli ne couvre que le cas inverse, où la cible a bien accepté l'offre mais où le marqueur n'est pas retrouvé.

Corollaire : rien ne permet d'avertir l'utilisateur que la cible a refusé. Il n'existe aucun retour de la cible vers la source, et le seul indice disponible côté fenêtre — un dragend à dropEffect: 'none' — ne distingue pas un refus d'un geste abandonné. Écart assumé par l'utilisateur : « il faut que la cible soit valide ».

#61 — SFTPSession.download() ne consulte jamais l'annulation : seule une exception arrête la boucle

La boucle du composant natif est un while (true) de handle.read() puis await transfer.write(chunk), sans un seul isCancelled(). Appeler cancel() sur le FileTransfer ne l'interrompt pas : elle continue de lire le fichier entier depuis le serveur. Le seul levier d'arrêt est une exception levée depuis write(), c'est-à-dire depuis notre propre code.

Corollaire rencontré le 2026-08-02, en servant un téléchargement par HTTP : attendre response.once('drain') sur une réponse dont le client est parti ne se résout jamais — l'événement ne viendra plus. La boucle restait suspendue indéfiniment, la ligne du panneau affichait « en cours » pour le reste de la session, et le canal SFTP restait ouvert dessus. Symptôme observé : annuler la boîte « Enregistrer sous » laissait un téléchargement qui n'avait jamais réellement commencé. → Toute attente sur un flux réseau doit écouter close et error en plus de l'événement attendu, et un write() doit refuser d'écrire sur un flux mort au lieu de le tenter.

À distinguer dans l'affichage : un client qui s'en va est une annulation (writableFinished faux sur close), un transport qui meurt est une interruption — même exception, deux causes, et les confondre fait dire « annulé » à ce que personne n'a annulé.

#60 — une case à cocher nue est invisible sur le thème de Tabby : utiliser <toggle>

Un input.form-check-input écrit à la main dans un onglet de réglages ne se voit pas du tout une fois décoché : Bootstrap ne dessine son interrupteur que dans un conteneur .form-check.form-switch, et sans lui l'élément est un carré transparent dont la bordure se confond avec le fond sombre. Coché, il redevient visible — d'où un défaut qui ne se manifeste que sur la moitié des états, et qui se lit comme un contrôle manquant plutôt que comme un contrôle non coché.

ToggleComponent de tabby-core apporte ce conteneur et l'apparence des réglages natifs. Il porte /** @hidden */, qui ne prouve rien (#13) : il figure bien dans les exports du module dans le bundle installé, et TabbyCoreModule étant importé par ce plugin, le sélecteur toggle est disponible dans nos templates. Il étend CheckboxComponent et fournit NG_VALUE_ACCESSOR, donc [ngModel]/(ngModelChange) fonctionnent tels quels. Les typings npm ne l'exportent pas — sans conséquence, un sélecteur de template n'est pas typé.

#59 — SSHSession.open n'est jamais remis à false, et un onglet qui se reconnecte garde son identité

Deux faits distincts, découverts ensemble le 2026-08-02 en cherchant pourquoi le correctif du panneau SFTP mort n'avait aucun effet. Le premier complète le #37 et le durcit :

Combinés, ces deux faits rendaient le panneau SFTP définitivement mort après une perte de connexion : sync() sortait sur tab === this.boundTab, et le garde-fou qui aurait dû le sauver testait sshSession?.open. Symptôme à reconnaître — « Session closed » remonté par russh à chaque geste dans un panneau qui, lui, s'affiche normalement.

La vivacité d'une session se lit à un seul endroit

isLiveSSHTab() (src/tabs.ts) porte le test des deux conditions, et les deux blocs qui en dépendent — « Sessions actives » et le panneau SFTP — l'appellent. Ils avaient divergé : la liste retirait une session que le panneau continuait de servir. Ne pas réécrire ce test sur place, l'appeler.

#50 — SFTPSession.stat() perd les permissions et la date : ne jamais s'en servir pour observer une entrée

Le piège le plus coûteux rencontré à ce jour

russh construit le résultat de stat() par Object.assign({}, md) sur un objet napi, dont les accesseurs ne sont pas des propriétés propres énumérables : rien n'est copié. Seuls type et size sont réaffectés à la main juste après. Tout le reste revient undefined, et Tabby le convertit sans broncher — permissions devient un mode 0, mtime une date au 1er janvier 1970.

Le comble : readDirectory() de la même bibliothèque copie chaque champ un par un, et porte le commentaire « Can't just spread a napi object » — exactement la faute que stat() commet.

Ce que ça casse, et qui ne ressemble pas à ça de l'extérieur : un chmod de restauration silencieusement sauté (mode 0), une comparaison de fraîcheur qui ne tient plus que par la taille — donc une modification distante de même taille passe inaperçue —, et tout test de type fondé sur le mode, comme le garde-fou des liens vers un dossier du #45, définitivement faux.

→ Lire une entrée distante par readdir du dossier parent (src/remoteEntry.ts), jamais par stat(). Le surcoût d'un listing est incontournable : les permissions et la date n'ont pas d'autre source ici.

#51 — NotificationsService.notice() impose une seconde d'affichage

Son implémentation code en dur { timeOut: 1000 }, sans paramètre. C'est convenable pour « copié », et beaucoup trop court pour un message demandant une action — « le fichier a changé sur le serveur », « reglissez-le pour la version à jour » : ils étaient tout simplement manqués. info() et error() laissent le défaut de toastr.

→ Passer par ToastrService directement (src/notices.service.ts). ngx-toastr étant un external webpack, c'est l'instance de Tabby : les toasts s'empilent avec les siens et sont identiques, seule la durée change.

#53 — SFTPSession.upload() n'applique jamais getMode()

Il écrit dans <path>.tabby-upload, unlink la cible puis renomme par-dessus. Le fichier qui atterrit porte donc le mode du fichier temporaire, c'est-à-dire l'umask du serveur — getMode() du transfert n'est appelé nulle part. Chaque enregistrement d'un script 0755 le rendait non exécutable, sans un mot.

→ Un chmod explicite après l'envoi, avec le mode lu juste avant l'écrasement (via #50, pas via stat()). Le mode porté par la ligne affichée ne convient pas : il date du dernier readdir et un chmod fait au terminal depuis n'y figure pas — le restaurer annule alors le changement de permissions au lieu de le préserver.

#13 — @hidden ne veut pas dire "non exporté"

Les deux sont indépendants. EditProfileModalComponent est @hidden ET absente du bundle réel (inutilisable) ; SettingsTabComponent est elle aussi @hidden mais bel et bien exportée (typings et bundle). @hidden n'exclut que de la doc générée. → Toujours vérifier les deux niveaux : typings/index.d.ts ET le bloc __webpack_require__.d(...) de dist/index.js compilé.

#17 — EditProfileModalComponent : cumul des pièges #13 et #6

Exportée par le bundle réel mais absente des typings npm (TS2614 à la compilation si import direct). Pire : le champ s'appelle partialProfile à l'exécution alors que les typings npm obsolètes le nomment profile — utiliser ce dernier compilerait sans erreur mais ferait planter silencieusement la modale. → Augmentation de type dédiée (src/tabby-settings-augment.d.ts) avec les vrais noms de champs, vérifiés directement dans le bundle compilé (jamais seulement les typings ni le .ts source de l'app).

#75 — Vérifier qu'un composant de l'hôte déclare bien une entrée : lire propName, jamais templateName — la doc d'Angular donne l'exemple à l'envers

Assigner un @Input() à la main sur une instance ne prouve rien : la propriété est créée si elle n'existe pas, sans erreur. Pour savoir ce qu'un composant déclare *vraiment*, reflectComponentType() d'@angular/core est la bonne porte — API publique (@publicApi, depuis 14.1), donc préférable à la table brute ɵcmp.inputs : se prémunir d'un champ non contractuel en en lisant un autre ne protège de rien. Vérifiée aux deux niveaux du #13 : déclarée dans les typings npm d'Angular 15.2.10, et présente comme function reflectComponentType exportée dans le bundle que Tabby charge.

Restent deux façons de lire son résultat de travers, et les deux donnent une alerte permanente sur un hôte parfaitement sain.

Corollaire pour qui serait tenté par ɵcmp.inputs quand même : cette table est indexée par le nom public. Mesuré sur l'app installée — { profile: 'partialProfile', profileProvider: 'profileProvider', … } — donc y chercher la clé partialProfile échoue à tous les démarrages.

Deux points de mise en œuvre : en JIT, ɵcmp est un accesseur paresseux, et lire le miroir peut être ce qui déclenche la compilation du composant — sans risque ici, le build de Tabby remplaçant templateUrl par un template en ligne, donc sans ressource non résolue sur laquelle échouer. Et un miroir null ne veut pas dire « les champs ont disparu » mais « Angular n'a rien voulu dire » : c'est un verdict à part, qui ne doit jamais valoir échec.

#32 — Deux paquets tabby-ssh de même numéro de version mais de contenu différent

Le numéro de version ne départage rien

node_modules/tabby-ssh (npm, utilisé à la compilation) et C:\Program Files\Tabby\resources\builtin-plugins\tabby-ssh (ce qui tourne réellement) annoncent tous deux 1.0.231-nightly.0 — et n'ont pourtant pas les mêmes typings. Constaté le 2026-07-29 sur components/sftpPanel.component.d.ts : la version installée déclare downloadItem(), downloadFolder(), filteredFileList, showFilter et filterText, absents de la version npm. C'est le piège #6 (typings en retard) privé de son seul signal d'alerte habituel, le décalage de version.

→ Corollaire de la méthode du piège #13 : la référence est toujours le paquet de builtin-plugins/, jamais celui de node_modules/, même quand les deux affichent le même numéro. Vérifier par un diff direct entre les deux arborescences typings/ plutôt qu'en comparant les package.json.

Attention en lisant ces diffs : les deux copies n'ont pas les mêmes fins de ligne (CRLF côté installé), donc diff annonce des fichiers intégralement différents alors qu'ils sont identiques. session/sftp.d.ts est dans ce cas ; seul sftpPanel.component.d.ts diverge réellement.

#34 — Tabby ne met en cache que quatre modules tabby-* : tout autre présent dans le node_modules du plugin est chargé une seconde fois

Critique — instanceof muet, symptôme trompeur

Dans le chargeur de plugins de l'app (app.asar) :

const builtinModules = [
    ...Object.keys(cachedBuiltinModules),
    'tabby-core', 'tabby-local', 'tabby-settings', 'tabby-terminal',
]

Ces modules-là sont résolus une fois puis servis à l'identique à tous les plugins. Tout autre tabby-*tabby-ssh en particulier — passe par la résolution Node normale, qui commence par le node_modules/ du module appelant. Un plugin qui a sa propre copie en dépendance charge donc une seconde instance du module.

Conséquences observées le 2026-07-29 en montant le panneau SFTP : tab instanceof SSHTabComponent toujours faux alors que tab.constructor.name vaut bien 'SSHTabComponent' (deux fonctions homonymes distinctes), et — plus grave car silencieux — le SFTPPanelComponent importé appartient à un SSHModule qu'Angular n'a jamais bootstrappé, donc dépourvu de portée JIT pour ses directives/pipes. Le symptôme ne dit rien de la cause : ici, un panneau bloqué sur « en attente d'une session SSH active » avec une session parfaitement ouverte.

→ Signature à reconnaître : x.constructor.name === 'Foo' mais x instanceof Foo faux. Discriminant utile pour savoir quelle copie on tient : une méthode présente dans le bundle installé et absente de celui de npm (piège #32), par exemple SFTPPanelComponent.prototype.onFilterChange.

→ Correctif appliqué : tabby-ssh retiré des devDependencies, ses typings vendorisés dans src/types/tabby-ssh/ et mappés par paths dans tsconfig.json (voir src/types/tabby-ssh/PROVENANCE.md). Sans copie locale, la résolution retombe sur builtin-plugins/. Le bug ne se manifestait qu'en dev — un utilisateur installant depuis npm n'a pas les devDependencies, donc obtenait déjà le bon module ; d'où la leçon générale : ne jamais conclure d'un « ça marchera en prod » que le dev est sain, ni l'inverse.

S'applique à l'identique à tout futur tabby-* hors des quatre listés (tabby-electron, tabby-plugin-manager, tabby-serial, tabby-telnet…).

Une sonde le dit désormais, au lieu de le laisser deviner (2026-08-03) : isSSHTab() dans src/tabs.ts est le point unique par lequel passent tous les rétrécissements instanceof SSHTabComponent, et il compare le constructor.name quand le test échoue — exactement la signature ci-dessus. Un avertissement console part alors, une seule fois. À savoir avant de s'y fier : la sonde ne peut rien voir tant qu'aucun onglet SSH n'est ouvert, et le contrôle de préconditions au démarrage (src/hostCompat.ts) ne l'attrape pas — dans cet état les classes existent, ce sont simplement les mauvaises.

#35 — SFTPPanelComponent.getModeString() est faux sous Windows : hériter d'un composant, c'est hériter de ses bugs

Un rendu plausible mais faux

La méthode masque item.mode contre les constantes de Node (constants.S_IXUSR, S_IRGRP, S_IWOTH…). Or Windows ne définit que S_IRUSR, S_IWUSR et S_IFDIR ; toutes les autres valent undefined, mode & undefined vaut 0, et chacun de ces bits sort en tiret. Un dossier en 775 s'affiche donc drw------- quelles que soient ses permissions réelles. Constaté le 2026-07-29 dans le panneau SFTP de la sidebar, en comparant à la colonne octale calculée localement (mode & 0o777), elle correcte.

SidebarPlusSftpBrowserComponent.longMode() recalcule la chaîne drwxrwxr-x à partir des bits, sans passer par constants.

→ Leçon générale, au-delà de ce cas : hériter d'un composant natif fait aussi hériter de ses bugs, et un affichage faux ne lève aucune erreur. Toute méthode de formatage reprise de Tabby est à confronter à une valeur connue avant d'être affichée — d'autant que celle-ci ne se trompe que sur cette plateforme, donc jamais chez ses auteurs.

#36 — toLocaleDateString() sans argument rend de l'anglais américain dans Electron

La locale JS par défaut du renderer est en-US indépendamment de la langue de Windows et de celle réglée dans Tabby : une date sort 7/23/2026 au milieu d'une interface en français — et surtout, jour et mois se retrouvent silencieusement inversés à la lecture. Tabby expose LocaleService (exporté par tabby-core, vérifié dans le bloc d'export runtime) dont getLocale() donne la langue réellement configurée. → Toujours lui passer this.locale.getLocale(). C'est aussi ce qui explique que le panneau SFTP natif affiche bien « 23 juil. 2026 » : son pipe tabbyDate passe par ce service.

#37 — sshSession.open reste vrai après la fin du shell : ce n'est pas le transport qui dit si une session est vivante

Constaté en test manuel le 2026-07-29 sur la section « Sessions actives » : un exit côté serveur déconnecte bien le terminal (l'onglet bascule sur « Reconnecter ») mais la session restait listée comme active indéfiniment. SSHTabComponent.sshSession est le transport SSH — compté par référence pour le multiplexage, et porteur du canal SFTP — il survit donc délibérément au shell. Ce qui répond honnêtement à « cette session est-elle vivante » est SSHTabComponent.session (la SSHShellSession) : Tabby la met à null à la fin de la session (onSessionDestroyed()setSession(null), vérifié dans le tabby-terminal compilé de l'app installée, lignes ~42077 et ~42156), et c'est aussi ce sur quoi la bannière « Reconnecter » de l'onglet se règle. → Tester les deux : session pour la vivacité, sshSession?.open pour garantir qu'une ligne affichée peut toujours ouvrir son SFTP. Le piège #6 distingue déjà ces deux champs côté SFTP ; ici c'est leur durée de vie qui diffère, pas seulement leur type.

#38 — un <a> dans un <a> est silencieusement désimbriqué par le parseur HTML

Les lignes de l'arbre sont des a.tree-item et leurs boutons d'action des div.action, pas des <a> — ce n'est pas un hasard de style. Un <a> imbriqué dans un <a> est invalide : le parseur ferme l'ancre extérieure et sort l'ancre intérieure du flux, ce qui casse la mise en page de la ligne sans aucune erreur ni avertissement. Le motif .actions > .action en div est à reprendre tel quel pour toute nouvelle ligne cliquable. À ne pas confondre avec le piège #21, qui portait sur l'indentation pug : ici l'indentation est correcte, c'est le choix du tag qui casse.

#43 — Le renommage manuel d'un onglet vit sur l'onglet de haut niveau, jamais sur le pane

Constaté le 2026-07-30 en faisant suivre le nom d'onglet aux lignes de « Sessions actives ». Le titre affiché par Tabby est tab.customTitle || tab.title (tabHeader.component.pug), customTitle n'étant posé que par AppService.renameTab() — c'est donc le signal fiable d'un renommage manuel, par opposition à title qui bouge tout seul. Mais les deux seuls points d'entrée du renommage (double-clic sur l'en-tête et entrée « Rename » du menu contextuel) passent this.tab, l'onglet de haut niveau : pour une session ouverte dans un split, le customTitle est porté par le SplitTabComponent et le pane n'en a jamais. Lire customTitle sur un pane retourne donc systématiquement vide.

tab.customTitle || tab.topmostParent?.customTitleparent est bien renseigné sur chaque pane par SplitTabComponent, et topmostParent (BaseTabComponent) remonte la chaîne. Les deux sont présents dans les typings npm comme dans le bundle compilé (piège #13). Conséquence à assumer côté affichage : deux panes d'un même onglet renommé portent le même libellé — c'est exactement ce que montre leur en-tête d'onglet. Corollaire pour le rafraîchissement : renameTab() se termine par emitTabsChanged(), donc un abonnement à tabsChanged$ voit le changement immédiatement, sans attendre un sondage périodique.

#44 — Aucun sélecteur de fichier de Tabby n'accepte de dossier de départ ; @electron/remote est atteignable depuis un plugin, mais pas par un import

Constaté le 2026-07-30 en construisant le choix de l'éditeur distant. Tabby offre deux façons d'ouvrir un dialogue de fichier, aucune paramétrable : PlatformService.startUpload() et FileProvider.selectAndStoreFile() figent leurs properties et n'exposent ni defaultPath ni filters — le dialogue s'ouvre donc là où le système l'a laissé, en pratique le dossier des téléchargements. Deux pièges de plus si l'on passe par les providers : FileProvidersService.selectAndStoreFile() demande d'abord quel stockage dès que plus d'un provider est disponible, et Tabby en a un second (VaultFileProvider) dont le résultat est une clé vault://, pas un chemin — viser directement le provider nommé Filesystem évite la question et le résultat inutilisable ; son retour est préfixé file:// et il lève à l'annulation au lieu de rendre null.

→ Pour un vrai dialogue paramétrable, @electron/remote. Il est résolvable depuis un plugin tiers parce que le chargeur ajoute app.asar/node_modules au NODE_PATH (initModuleLookup(), vérifié dans le bundle.js de l'app), mais il n'est pas dans les cachedBuiltinModules — seuls Angular, rxjs, ng-bootstrap, ngx-toastr et zone.js y sont, avec les quatre tabby-* du piège #34. Le charger par un import ferait échouer le build webpack, et le déclarer en externals hisserait le require au chargement du module : un module absent ne dégraderait plus une fonctionnalité, il empêcherait le plugin entier de se charger. Passer par le require global — (window as any).require('@electron/remote') — dans un try, au moment de l'appel, avec un repli sur le sélecteur de Tabby.

#45 — SFTPFile.isDirectory vient d'un champ que le protocole SFTP v3 ne transporte pas

Trouvé en test le 2026-07-30, sur un lien symbolique vers un dossier qui partait au téléchargement au lieu d'être parcouru (« Impossible de télécharger infra », avec un Status Failure — ce qu'OpenSSH renvoie pour EISDIR). SFTPSession remplit isDirectory/isSymlink depuis entry.metadata.type, or un serveur ne répond à SSH_FXP_STAT qu'avec un mot de permissions : le type n'est pas dans la réponse. Le stat() de la cible d'un lien rend donc isDirectory: false pour un dossier — le readdir(), lui, est correct, ce qui rend le défaut invisible tant qu'on ne suit pas un lien.

→ Lire le type dans le mode : (mode & 0o170000) === 0o040000, en gardant isDirectory comme premier test. C'est déjà ce que fait le template natif de Tabby (getModeString() teste mode & S_IFDIR). Règle générale à retenir : un booléen dérivé d'un champ que le protocole ne porte pas ment silencieusement, et un Status Failure générique côté russh vaut la peine d'être retraduit en code POSIX avant de suspecter son propre code.

#46 — La fenêtre « Ouvrir avec » de Windows ne rend pas le programme choisi ; la liste des processus, si

Construit le 2026-07-30 pour mémoriser l'éditeur choisi au premier double-clic. rundll32 shell32.dll,OpenAs_RunDLL <chemin> ouvre le fichier avec l'application retenue et ne renvoie rien ; aucune API Windows publique ne restitue ce choix. La trace registre qu'elle laisse (FileExts\<.ext>\OpenWithList) est indexée par extension — inutilisable ici, où les fichiers ouverts sont des fichiers de conf distants le plus souvent sans extension.

→ L'application reçoit notre chemin temporaire sur sa ligne de commande : un Get-CimInstance Win32_Process filtré sur ce chemin l'identifie, quelle que soit l'extension. Trois détails qui font la différence entre marche et ne marche pas : (1) le callback d'execFile sur rundll32 se déclenche à la fermeture de la boîte, c'est le bon moment pour commencer à chercher ; (2) un seul processus PowerShell qui sonde lui-même en boucle, jamais un spawn par tentative — le coût de démarrage de PowerShell dépasserait celui de la recherche entière ; (3) retenir le premier token de la ligne de commande et non ExecutablePath : pour une application du Store celui-ci pointe dans WindowsApps, dont les ACL refusent généralement un spawn direct, là où la ligne de commande porte le lanceur qui fonctionne (mesuré sur Notepad, qui rend WindowsApps\…\Notepad.exe mais se lance en C:\WINDOWS\system32\notepad.exe). Et pour composer le script : le chemin s'injecte en littéral PowerShell entre guillemets simples avec doublage des quotes internes — JSON.stringify() échappe les antislashs à la mode JSON et PowerShell ne les déséchappe pas, chaque \ serait cherché comme \\.

Points de veille — ⚠️ à revérifier après update Tabby

Édition de profil (clic droit → « Éditer… ») ✅ Point de veille supprimé (2026-08-03)

Ce point décrivait le pilotage du DOM interne non versionné de tabby-settings (.collapse-item cliqués un à un, ligne retrouvée par le texte d'un span, .modal-content sondé pour détecter la fermeture) — le plus fragile du plugin. Ce chemin n'existe plus : editProfile() ouvre EditProfileModalComponent directement, comme tabby-settings le fait pour sa propre liste, puis écrit par writeProfile().

Ce qu'il faut en retenir, et qui n'est pas propre à ce cas

Le commentaire qui justifiait le pilotage DOM affirmait qu'aucune API publique n'existait, alors que le même fichier ouvrait déjà cette modale quelques centaines de lignes plus haut pour « Nouveau profil… », et que src/tabby-settings-augment.d.ts la déclarait. Un contournement survit à la raison qui l'a fait naître, et son commentaire devient l'argument qui empêche de le rouvrir. Quand un contournement pilote le DOM de l'hôte, revérifier son motif avant de bâtir dessus — la méthode du piège #13 répond en quelques minutes.

Ce qui reste à surveiller, bien plus étroit : les deux @Input() passés à la modale (partialProfile, profileProvider) et le contrat de providerForProfile()/writeProfile(). Tous quatre sont dans les typings npm ou dans l'augmentation dédiée, donc un renommage se voit à la compilation — sauf pour partialProfile, dont l'augmentation « ment » à TypeScript par construction (piège #17).

Une limite héritée de Tabby, ni corrigée ni aggravée : writeProfile() ne fait rien si le profil n'est pas dans config.store.profiles — le cas d'un profil fourni par un provider (#74). L'édition d'un tel profil se perd en silence, chez nous comme dans l'onglet Profils natif.

SettingsTabComponent.activeTab

Vrai @Input() documenté mais toujours @hidden donc non contractuel. Vérifier que activeTab: 'profiles' amène toujours sur le bon sous-onglet.

SFTPPanelComponent/SFTPSession/SSHTabComponent

Vérifié le 2026-07-29 sur le tabby-ssh réellement installé, aux deux niveaux (typings + bloc d'export webpack de dist/index.js, méthode du piège #13). Le bloc d'export runtime complet est : AutoPrivateKeyLocator, PasswordStorageService, PortForwardType, SFTPContextMenuItemProvider, SFTPPanelComponent, SFTPSession, SSHAlgorithmType, SSHMultiplexerService, SSHProfileImporter, SSHTabComponent. À revérifier après update :

Rendu validé dans l'app le 2026-07-29

Un SFTPPanelComponent créé par createComponent() se rend correctement hors de son module d'origine : les directives/pipes de la portée de SSHModule (dropZone, pipe filesize) sont bien résolues, la compilation JIT du composant ayant déjà eu lieu quand Tabby a bootstrappé son propre module. Mesuré en direct dans la sidebar sur une session réelle : panneau à 287 px de large, .body à 994 px en overflow-y: auto (la chaîne flex tient), breadcrumb fonctionnel, 20 lignes de fichiers distants listées. La réutilisation est donc acquise — pas de réimplémentation au-dessus de SFTPSession.readdir().

À une condition, sans laquelle rien de tout cela ne marche : que le plugin tienne la vraie classe et non un doublon, cf. piège #34.

profile-tree toujours non exporté

Vérifier périodiquement (montée majeure de tabby-core) s'il a fini par être exporté — simplifierait la maintenance en permettant de réutiliser le composant natif au lieu de la copie locale.

EditProfileModalComponent — noms des deux entrées ✅ Vérifié au démarrage depuis le 2026-08-03

Le point le plus sévère du plugin jusqu'à cette date, parce que sa panne était muette des deux côtés : TypeScript se taisait, l'augmentation affirmant les noms de champs (piège #17) ; Angular aussi, assigner une propriété inconnue à une instance étant parfaitement légal. Un renommage dans Tabby compilait, puis ouvrait une modale vide, sans une ligne de journal. Seul un essai manuel de « Nouveau profil… » le révélait.

Ce qui a changé : src/profileModal.ts est le point unique par lequel la modale s'ouvre — les deux entrées n'y sont nommées qu'une fois, et c'est cette même liste que la précondition edit-profile-modal confronte à ce qu'Angular déclare, par reflectComponentType() (piège #75, à lire avant d'y toucher : la table se lit par propName, l'entrée portant l'alias public profile). Un renommage donne désormais, au démarrage, un message nommant le champ absent et les entrées réellement déclarées, une notice à l'utilisateur, et un refus d'ouvrir — plutôt qu'une fenêtre vide dont personne ne peut rien tirer.

Ce qui reste à surveiller, et que rien ne vérifie : le contrat de providerForProfile()/writeProfile(). Tous deux sont dans les typings, donc un renommage s'y verrait à la compilation.

Ce que ce contrôle ne fait pas

Il ne rend le plugin compatible avec rien. Une version de Tabby qui renomme ces champs casse la création et l'édition de profils exactement comme avant — la seule différence est qu'elle le dit. Relire cette fiche comme un point supprimé serait un contresens.

#78 — Un profil ne porte aucune référence au coffre-fort : c'est le coffre qui est indexé par les coordonnées de connexion

L'intuition naturelle — et ce que supposait la fiche de roadmap du partage de groupe — est qu'un profil garde un pointeur vers son secret, à purger avec soin avant de l'exporter. C'est l'inverse. PasswordStorageService range le secret sous une clé dérivée du profil : {user, host, port} pour un mot de passe, {hash: id} pour une phrase de passe de clé. Sans coffre-fort activé, ces mêmes secrets vont au trousseau de l'OS par keytar — hors du profil également.

Intégrité de config.yaml & persistance

La catégorie la plus critique du projet — a réellement corrompu des données de production une fois.

#12 — getProfileGroups() ne clone pas profondément — pollue config.yaml

Critique — cause racine, ne jamais réintroduire

A réellement corrompu la configuration de production de l'utilisateur une fois (groupes orphelins, entrées dupliquées) lors d'un test de re-parentage.

profilesService.getProfileGroups() peut renvoyer des références directes vers config.store.groups (contrairement à getProfiles({clone:true}), qui clone bien). buildGroupTree() attache alors un .children calculé directement sur ces objets vivants ; au config.save() suivant (n'importe lequel, favoris compris), cette propriété polluante est sérialisée → groupes dupliqués/orphelins dans config.yaml.

Fix retenu : structuredClone() sur groups juste après getProfileGroups(), avant toute manipulation. Le plugin ne mute jamais que sa propre copie.

Depuis le 2026-08-03, ce clone n'est plus une discipline à tenir mais un point unique : readProfileGroups() (src/profileGroups.ts) est le seul appel du plugin à getProfileGroups(), et il rend un clone par construction. Ne pas appeler l'API directement ailleurs — c'est le même raisonnement que isSSHTab() pour instanceof SSHTabComponent. Voir fragile-5 pour ce que l'API garantit réellement aujourd'hui, et pour la sonde qui le vérifie.

Corollaire : profilesService.writeProfileGroup(group) fait une recherche plate, racine uniquement (config.store.groups.find(...)) — fondamentalement inadaptée pour déplacer un groupe entre children: imbriqués (silencieux en cas d'échec). Le re-parentage passe donc par recréation (newProfileGroup) + migration des profils un par un (writeProfile) + suppression de l'ancien (deleteProfileGroup) — jamais par réassignation directe de parentGroupId.

Discipline de méthode qui en découle : toute nouvelle fonctionnalité touchant groupes/profils doit être testée d'abord sur des entrées jetables (grp-zzz-test-*), jamais directement sur les vraies données.

#16 — ConfigProvider.defaults doit déclarer chaque clé individuellement

Une clé sous config.store.sidebarPlus non déclarée dans les defaults mute très bien en mémoire mais ne persiste jamais dans config.yaml — silencieux, aucune erreur. Touché recentIcons et groupOrder (ce dernier croyait fonctionner depuis le début). → Toute nouvelle clé sidebarPlus.X doit être ajoutée aux defaults de SidebarPlusConfigProvider (src/configProvider.ts), même vide.

#23 — Muter une propriété imbriquée de config.store.sidebarPlus.X sans réassignation explicite ne persiste jamais

Distinct du piège #16

Pas un problème de defaults non déclaré (groupOrder l'était bien) — la clé existe, mais la mutation ne "prend" pas.

this.config.store.sidebarPlus.groupOrder ??= {}; this.config.store.sidebarPlus.groupOrder[key] = valeur (mutation imbriquée en place, jamais suivie d'une réassignation du haut niveau) ne persistait jamais dans config.yaml — silencieux, aucune erreur, et l'effet était même invisible en mémoire dans la même session (le réordonnancement de dossiers par glisser-déposer "revenait toujours à sa position initiale", y compris sans redémarrer Tabby). Bug réel introduit dès l'implémentation d'origine du glisser-déposer (avant même le chantier Workspaces), resté invisible jusqu'à un diagnostic complet le 2026-07-28 (comparaison config.yaml avant/après, logs de debug injectés temporairement, CDP). Repéré par comparaison avec les autres écritures du plugin (favorites/favoriteGroups/recentIcons/workspaces), qui terminent toutes par une réassignation explicite this.config.store.sidebarPlus.X = valeur — seule groupOrder (branche "Tous" de persistGroupOrder()) s'en dispensait. → Toute écriture dans config.store.sidebarPlus.* doit se terminer par une affectation explicite de la clé de premier niveau touchée, jamais seulement une mutation de sous-objet en place.

#24 — CDK retient le premier cdkDropListConnectedTo qui matche, et un conteneur ancêtre matche toujours

Cause n°1 de « impossible d'imbriquer un dossier dans un autre »

Symptôme : tout dépôt de dossier retombait à la racine, quelle que soit la précision de la visée.

Mécanique CDK 15 (lue dans le source) : DragRef._updateActiveDropContainer appelle this._initialContainer._getSiblingContainerFromPosition(...), qui fait this._siblings.find(s => s._canReceive(item, x, y)). Or _canReceive se termine par elementFromPoint === nativeElement || nativeElement.contains(elementFromPoint). Deux conséquences cumulées : l'ordre du tableau passé à cdkDropListConnectedTo fait foi (find, pas « le plus spécifique »), et un conteneur qui contient le point dans le DOM matche toujours. Comme #groups-root contient toutes les listes imbriquées et qu'il était placé en premier, il gagnait systématiquement. → Trier les ids par profondeur décroissante et mettre groups-root en dernier (sortGroupIdsByDepthDesc()). Vérifié le 2026-07-28 par pilotage CDP du glisser-déposer : avant correctif, containerId valait groups-root sur 7 tentatives sur 7.

profileListIds n'a pas le même problème : profiles-<parent> et groups-<parent> sont frères, donc une liste de profils n'en contient jamais une autre.

#25 — Toute règle CSS qui change la taille d'un cdkDropList en réaction au glisser est doublement cassée

Cause n°2 du même bug

Tentative « rendre les zones de dépôt visibles » via .cdk-drop-list-receiving { min-height: 26px } — a aggravé le problème au lieu de le résoudre.

  1. Reflow massif. CDK marque toutes les listes connectées receiving d'un coup, au démarrage du glisser. Avec 5 dossiers repliés au-dessus du curseur, une croissance de 8px→26px décale tout ce qui est en dessous de ~90px : le dossier visé n'est plus sous le pointeur. Mesuré : elementFromPoint renvoyait une liste située 90px plus haut avant le drag.
  2. Rectangles déjà mis en cache. DropListRef._startReceiving() appelle _cacheParentPositions() puis émet receivingStarted — l'événement qui fait ajouter la classe. La boîte agrandie n'est donc jamais celle contre laquelle le test de collision compare. _canReceive exigeant à la fois le rect en cache et elementFromPoint, le dépôt ne s'enregistre silencieusement jamais.

→ En réaction à .cdk-drop-list-receiving, ne changer que des propriétés sans effet sur la géométrie ni sur le cache : couleur, contour… et pointer-events (qui n'affecte pas le layout, donc pas le rect en cache, mais bien elementFromPoint).

#26 — Agrandir une zone de dépôt sans ajouter d'espace : :empty + marge négative + pointer-events

« Imbriquer A dans B » se traduit, dans le modèle CDK, par un dépôt sur la liste d'enfants de B — qui a une hauteur nulle dès que B est replié ou n'a pas encore de sous-dossier. Les rembourrer ajoute de l'espace mort sous chaque dossier (l'utilisateur a explicitement demandé l'inverse).

Solution retenue (sidebarTree.component.scss) : quand — et seulement quand — la liste est vide, min-height: 16px + margin-top: -16px. La boîte de marge contribue 0px au flux (l'arbre devient plus compact qu'avec un espaceur de 8px) mais la boîte de bordure recouvre la moitié basse de la ligne du dossier au-dessus. D'où l'ergonomie : moitié basse d'une ligne = « déposer dans ce dossier », moitié haute = « réordonner à côté ».

pointer-events: none au repos est ce qui rend le recouvrement sûr — sans lui, l'overlay volerait les clics de la ligne (repli, menu contextuel). La classe .cdk-drop-list-receiving le repasse à auto pendant le glisser ; c'est licite malgré le piège #25 puisque pointer-events n'a aucun effet sur le layout, donc aucun sur le rect déjà mis en cache.

Attention à :empty

Fonctionne bien ici car les nœuds-commentaires d'Angular (*ngIf/*ngFor) n'affectent pas :empty au sens CSS. À revérifier si le template gagne du texte statique ou des espaces significatifs dans ces div.

#28 — Un placeholder CDK à hauteur normale rend tout dépôt « vers le bas » impossible

Cause n°3, et la vraie racine

Symptôme après les correctifs #24/#25 : imbriquer un dossier fonctionnait en glissant vers le haut, jamais vers le bas — quelle que soit la distance (voisin immédiat comme trois rangs plus bas).

Quand CDK entre dans une liste cible, il y déplace son placeholder. Si celui-ci occupe la hauteur d'une ligne, il libère en partant la place qu'il tenait au-dessus de la cible : la cible remonte de 29px, sort de sous le curseur, CDK ressort de la liste, le placeholder revient, la cible redescend… oscillation infinie, le dépôt n'a jamais lieu. En glissant vers le haut le placeholder est sous la cible, qui ne bouge donc pas — d'où l'asymétrie.

Mesuré : la boucle de visée automatique alternait indéfiniment entre deux positions distantes d'exactement une hauteur de ligne. Le phénomène persiste avec cdkDropListSortingDisabled — ce n'est pas le tri, c'est le placeholder.

→ Correctif : .cdk-drag-placeholder { height: 0 } (+ marges/paddings à zéro). Entrer ou sortir d'un dossier ne change alors plus aucune géométrie.

Ne pas utiliser display: none

Première tentative : l'oscillation disparaissait bien, mais l'événement de dépôt ne se déclenchait plus du tout. CDK anime la prévisualisation vers la boîte du placeholder à la fin du glisser (_animatePreviewToPlaceholder) ; sans boîte, la séquence ne se termine jamais et cdkDropListDropped n'est jamais émis. Il faut une boîte mesurable de hauteur nulle, pas l'absence de boîte.

#29 — cdkDragEnded est émis avant cdkDropListDropped

Dans DragRef._cleanupDragArtifacts(), l'ordre est ended.next() puis dropped.next(). Tout état de suivi remis à zéro dans le gestionnaire (cdkDragEnded) est donc déjà perdu quand le gestionnaire de dépôt le lit — silencieux, et le symptôme (« mon repli de secours ne se déclenche jamais ») ne pointe pas du tout vers l'ordre des événements. Remettre l'état à zéro dans (cdkDragStarted) à la place.

Concrètement ici : onGroupDragEnded() ne relâche que le suivi (draggedGroupId) et laisse délibérément hoveredGroupId intact pour que rescueTargetGroupId() puisse encore le lire.

#31 — Après un re-parentage, l'ordre des frères doit être persisté avec le nouvel id

Constaté dans la vraie config utilisateur

groupOrder d'un workspace contenait 526eac40: [a776a5b3] — un id qui n'existe dans aucun groupe.

Le re-parentage d'un dossier passe obligatoirement par recréation sous un nouvel id (piège #12). reparentGroup() appelle bien migrateWorkspaceGroupId() pour réécrire toutes les références, mais cette migration s'exécute avant que l'appelant ne persiste l'ordre des frères — or la liste persistée provient du nœud d'arbre rendu, qui porte encore l'ancien id. L'écriture repose donc par-dessus la migration un id mort : le dossier déplacé n'a plus aucune position enregistrée dans son nouveau parent et retombe silencieusement en fin de tri.

Deux conséquences en cascade, à traiter ensemble :

→ Faire remonter le nouvel id par reparentGroup() et le reporter sur le nœud glissé avant toute écriture d'ordre. Mutation sûre uniquement parce que ce nœud vient du structuredClone() de loadTreeItems() et jamais d'un objet vivant de config.store (piège #12).

#40 — Masquer des lignes pendant un glisser CDK (display: none) casse le pointage et le cache de CDK

Le symptôme n'est pas « ça s'affiche mal », c'est « le dépôt ne fait plus rien »

Tentative (2026-07-29) : pendant le glisser d'une sélection multiple, replier les autres lignes sélectionnées hors de la liste pour que le lot parte d'un bloc.

Deux dégâts distincts, dont un seul est le piège #25 déjà connu :

  1. Le pointeur se retrouve hors de toute ligne. Retirer 2 lignes du flux raccourcit la liste d'environ 60 px d'un coup. Le curseur — qui tient toujours la ligne attrapée, restée à hauteur du geste — se retrouve sous la dernière ligne restante. updateHoveredProfile() ne trouve donc plus rien, le placement retombe sur son repli et le dépôt se résout en non-opération. Mesuré par trace temporaire : hovered: null sur chaque tentative, avec un tableau résultat identique au tableau d'entrée.
  2. Les rects mis en cache par CDK deviennent faux, exactement comme au piège #25 — sauf que la liste rétrécit au lieu de grandir, donc _canReceive échoue par elementFromPoint plutôt que par le rect.

visibility: hidden, jamais display: none : la ligne disparaît à l'œil et conserve sa place, donc toutes les mesures — les nôtres comme celles de CDK — restent valides. Le prix est un espace vide transitoire là où étaient les lignes ; l'utilisateur l'a jugé souhaitable (« ça permet de se souvenir visuellement d'où on a pris les profils »). Faire disparaître les lignes et refermer la liste n'est pas atteignable sans réécrire le pointage.

Corollaire de méthode, déjà énoncé au piège #30 et reconfirmé ici : deux cycles de correction ont été perdus à raisonner sur ce que CdkDragDrop.currentIndex devrait valoir. Une trace de cinq lignes imprimant l'index, la ligne survolée et le tableau avant/après a tranché en un seul geste utilisateur. Sur un bug de placement, instrumenter avant de raisonner.

#62 — Un re-parentage de dossier change son id : tout ce qui est indexé par cet id doit migrer, ou revient à son défaut

Tabby n'offre aucun moyen de re-parenter un groupe (#12) : le seul chemin sûr est recréer sous un nouvel id, migrer, supprimer l'ancien. Le nouvel id est donc un fait acquis de l'opération, et tout état que ce plugin range par id de groupe doit suivre dans le même geste. Un état oublié ne provoque aucune erreur : il retombe silencieusement à sa valeur par défaut au prochain chargement de l'arbre, et laisse un id mort derrière lui.

Recensement au 2026-08-02 — migrateWorkspaceGroupId() les couvre tous :

Et le contenu du groupe compte autant que son id : recopier une liste de champs choisis à la main perd tout ce que ProfileGroup porte d'autre. Le champ defaults est le piège concret — Tabby le fusionne dans le ConfigProxy de chaque profil du dossier via getProviderProfileGroupDefaults(), donc le perdre dépouille les profils de tout leur héritage, en silence. → recopier le groupe entier moins id, profiles et children.

Reconstituer un appel du composant en test

getProfileGroups() ne remplit pas profiles sans qu'on le demande. Le composant appelle getProfileGroups({ includeNonUserGroup: true, includeProfiles: true }) ; un harnais qui reprend le nom de la méthode sans ses options observe un faux négatif — constaté le 2026-08-02, il avait fait conclure à tort que les profils ne suivaient pas leur dossier.

#68 — Un glisser-déposer dans l'arbre filtré persiste l'ordre de la recherche comme ordre réel

Quand la barre de filtre est active, onFilterChange() remplace rootGroups par un pseudo-groupe search (et, depuis le 2026-08-03, par les dossiers dont le nom correspond). Ce n'est pas l'arbre : c'est un aplatissement, dans l'ordre des résultats. Or les directives CDK de la template ne savent rien de cette distinction — onProfileDrop() appelle persistProfileOrder() comme pour n'importe quel dossier.

Les deux dégâts sont de gravité inégale, et c'est le premier qui compte :

[cdkDragDisabled]='filtering' sur les lignes de profil et de dossier. Ne pas traiter ce garde-fou comme une restriction d'ergonomie qu'on pourrait lever : tant que l'arbre affiché n'est pas l'arbre réel, aucun ordre déposé dedans n'est interprétable.

#27 — Get-Content/Set-Content -Encoding utf8 (PS 5.1) corrompt les accents de config.yaml

A réellement abîmé la config utilisateur

Un chemin de clé SSH …\Développement\… est devenu …\Développement\…, et un BOM a été ajouté en tête de fichier.

Windows PowerShell 5.1 ne fait pas un aller-retour propre en UTF-8 : chaque cycle lecture/écriture ré-encode les caractères non-ASCII, et l'effet se cumule sur plusieurs passes. → Pour toute édition scriptée de config.yaml, passer par Node (readFileSync/writeFileSync en 'utf8'), jamais par Get-Content/Set-Content. Et systématiquement diff contre la sauvegarde avant de considérer l'opération terminée : le dégât est totalement silencieux.

Points de veille — ⚠️ à revérifier après update Tabby

writeProfileGroup()/newProfileGroup()/deleteProfileGroup()

Utilisées uniquement selon la discipline du piège #12. Si le format de stockage des groupes dans config.yaml change, retester le re-parentage sur des groupes jetables avant de faire confiance au comportement existant.

Téléchargements servis par nous, et non par platform.startDownload() ✅ Deux appuis sur trois retirés le 2026-08-03

Depuis le 2026-08-02, l'entrée « Download » du menu contextuel sert le fichier par notre serveur HTTP local, écrit par Chromium (voir #transferts). Elle reposait sur trois appuis non contractuels ; deux ont cessé d'en être :

Les liens symboliques et les dossiers restent sur la voie héritée, comme avant : une offre DownloadURL sert exactement un fichier, et un lien ne se résout que par le stat() du #50, dont la taille est juste mais le mode faux. downloadFolder() ne passe pas par download() du tout.

Choix assumé de l'utilisateur le 2026-08-02, en connaissance de ce qu'il coûte : on renonce à la progression du menu de transferts natif (masqué par défaut de toute façon) pour gagner un échec visible — la voie native appelle sftp.download() sans l'attendre, donc sa rupture n'est observable nulle part.

Clone non garanti de getProfileGroups()/buildGroupTree() ✅ Point unique + sonde depuis le 2026-08-03

Le structuredClone() défensif (piège #12) part du principe que l'API peut renvoyer des références vivantes. Mesuré le 2026-08-03 : elle ne le fait plus. getProfileGroups() passe par getSyncProfileGroups(), qui rend deepClone(config.store.groups) (paquet clone-deep) — et une comparaison d'identité sur l'app en marche confirme qu'aucun groupe ne revient comme le même objet (6 groupes appariés, 0 partagé). La précaution est donc, aujourd'hui, une ceinture par-dessus des bretelles.

Elle reste : les bretelles sont un détail d'implémentation d'une méthode que personne n'exporte, et ce qu'elles retiennent est un config.yaml corrompu. Ce qui a changé, c'est où elle vit — readProfileGroups() (src/profileGroups.ts) est le seul appel du plugin à getProfileGroups(), et il rend un clone par construction. La discipline n'est plus à tenir, donc plus à oublier.

S'y ajoute la moitié qui manquait : une sonde compare une fois par session l'identité des objets rendus avec ceux de config.store.groups, et le signale en console si la garantie disparaît. Rien n'est cassé quand elle parle — le clone rattrape — donc elle ne s'adresse pas à l'utilisateur ; c'est la note du plugin qu'une hypothèse écrite dans un commentaire est redevenue vivante. Comparer par identité et non par valeur est ce qui la rend juste : getProfileGroups() pose editable et profiles sur ce qu'il rend, si bien qu'un objet partagé n'est jamais égal à celui qui est stocké.

#74 — config.store ne contient pas tout ce que l'arbre affiche : juger de la vie d'un id sur lui seul détruit des données

config.store.profiles et .groups ne portent que les entrées de l'utilisateur. L'arbre, lui, est construit par getProfileGroups({ includeNonUserGroup: true, includeProfiles: true }), et cet includeNonUserGroup passe includeBuiltin à getProfiles() : s'ajoutent donc les profils fournis par les providers et les groupes synthétiques qui les portent — built-in, plus un par nom de groupe déclaré par un provider. Rien de tout cela n'existe dans config.store.

Conséquence : tout code qui demande « cet id existe-t-il encore ? » à config.store répondra non pour une entrée parfaitement vivante, et ce qui suit cette réponse est en général une suppression silencieuse — un favori, un masquage, une position. Le symptôme est un réglage qui retombe à son défaut sans erreur, donc attribué à autre chose.

L'instantané non filtré à consulter à la place est rawGroupsSnapshot, que deleteGroup(), rescueTargetGroupId() et désormais pruneDeadOrderIds() prennent tous pour référence — ses groupes portent leurs profils, builtin compris.

Mieux encore : ne pas poser la question. Un id qui disparaît est connu au moment où il disparaît. forgetDeletedId() retire ses traces depuis deleteProfile()/deleteGroup(), sans rien déduire — c'est le pendant exact de migrateWorkspaceGroupId() (#62), qui les renomme quand l'id change, et la même liste est en jeu des deux côtés. Le re-parentage n'appelle donc pas le premier : il a déjà appelé le second, et l'ordre — migrer, puis supprimer — est ce qui garantit qu'il ne reste rien à collecter.

#76 — Un commentaire écrit dans config.yaml ne survit pas au premier config.save()

Tabby ne modifie jamais le fichier : il sérialise son état mémoire et réécrit tout. Tout ce qui n'existe que dans le texte — commentaires, ordre des clés, mise en forme — disparaît au premier enregistrement venu, et n'importe quoi déclenche un enregistrement (un favori, un pli de dossier, la fermeture de l'application).

Conséquence pratique, apprise en posant des entrées de test. Encadrer un bloc jetable par deux marqueurs de commentaire pour le retirer d'un geste ensuite paraît raisonnable et ne marche pas : à la fin de la passe, les marqueurs ne sont plus là et le script de retrait ne trouve rien. Un nettoyage doit cibler les entrées par nom ou par id, et purger leurs traces comme le fait forgetDeletedId() — sinon des ids morts restent dans groupOrder, hiddenGroupIds et favoriteGroups.

Le corollaire vaut pour toute écriture directe : Tabby doit être fermé. Ouvert, il écrase la modification au premier save, et peut même réintroduire ce qu'on venait de retirer, puisque son état mémoire fait foi.

#77 — defaults d'un dossier est un bloc par fournisseur : ses champs sont un cran plus bas qu'on ne les cherche

Sa forme est { ssh: { user, password, scripts… }, local: { … } }getConfigProxyForProfile() lit defaults?.[provider.id]. Un traitement écrit contre un bloc d'options ordinaire ne voit donc rien de ce qu'il contient : il inspecte le niveau des noms de fournisseurs, où aucun champ attendu ne se trouve.

Ce que ça a réellement coûté : la purge du partage de groupe (src/groupShare.ts) laissait passer defaults.ssh.scripts en entier — c'est-à-dire un script de login de dossier, que Tabby fusionne dans chaque profil du dossier, et où un mot de passe sudo se met couramment en dur. Le mot de passe voisin, lui, n'était rattrapé que par un filet heuristique sur les noms de champs. D'où purgeDefaults(), qui traite chaque bloc de fournisseur comme le bloc de niveau supérieur qu'il est.

Comment il a été trouvé, ce qui vaut plus que le cas

Non par relecture : l'export paraissait correct, le mot de passe avait bien disparu. Ce qui l'a révélé est un contrôle sur la catégorie du décompte — un mot de passe compté comme « champ sensible » au lieu de « mot de passe », donc retiré par le filet et non par la règle qui aurait dû s'en charger. Vérifier qu'un secret est parti ne suffit pas ; il faut vérifier par quelle règle il est parti. Le voisin qu'aucune règle ne couvrait, lui, ne partait pas.

Ce champ est aussi le plus facile à perdre en recopiant un dossier — voir #62, où son oubli dépouillait les profils de tout ce dont ils héritaient.

Git & GitHub

#9 — gh auth login authentifie le compte actif du navigateur

Pas un compte choisi dans le CLI. Vérifier le compte actif dans le navigateur avant, ou gh auth status --active après.

#10 — Auteur des commits ≠ compte GitHub utilisé

Indépendants : git config user.email détermine l'auteur affiché, le compte GitHub authentifié détermine qui pousse. Email noreply officiel pour committer "en tant que" un compte perso : {id}+{login}@users.noreply.github.com (id via gh api user --jq '{id, login}').

#11 — Réécrire l'historique : ce qu'il faut vérifier avant, et la fausse sauvegarde

Fait le 2026-08-01 pour purger l'attribution IA des 74 commits (git filter-branch --msg-filter). Selon le mode de permission actif, la commande peut être refusée — elle se relance alors par le préfixe !, côté utilisateur. Le git push --force qui suit, lui, ne se lance jamais sans accord explicite : il écrase l'état publié.

Une branche de sauvegarde créée avant ne protège de rien

filter-branch ... -- --all réécrit toutes les références, y compris la branche de sauvegarde faite juste avant. Ce qui sauve réellement, c'est refs/original/, que filter-branch écrit tout seul et qui garde les sommets d'origine (git reset --hard refs/original/refs/heads/master annule tout tant qu'ils existent).

#47 — Un diff nu contre la charte canonique signale une divergence totale alors que le contenu versionné est identique

core.autocrlf vaut true sur le poste de dev, alors que le dépôt canonique de la charte force eol=lf par son .gitattributes. Le fichier .AIRules/GOUVERNANCE-IA.md est donc en CRLF sur disque et sa source en LF : diff rapporte toutes les lignes comme différentes, ce qui ressemble exactement à une copie périmée ou adaptée au projet — le cas que la charte interdit.

Piège

Ne pas conclure à une copie divergente sur ce diff. Vérifier d'abord que les blobs Git sont identiques, ce qui est la seule comparaison indépendante du checkout :

git -C <projet> rev-parse HEAD:.AIRules/GOUVERNANCE-IA.md
git -C <clone-canonique> rev-parse HEAD:GOUVERNANCE-IA.md

Sur le contenu de travail, diff --strip-trailing-cr donne la même réponse. La comparaison des identifiants de version en pied de page reste le contrôle le plus rapide, et il suffit dans la plupart des cas.

#70 — Un push --force ne retire rien de GitHub

Réécrire l'historique puis forcer le push déréférence les anciens commits sans les supprimer : ils répondent encore par leur hash sur github.com et par l'API. Ne jamais présenter une purge comme terminée sur la seule foi du push.

Ce qui rend la survie définitive

Un seul fork suffit — les dépôts d'un même réseau partagent leur stockage — et une refs/pull/* est une vraie référence, permanente. Les deux se vérifient avant de conclure quoi que ce soit :

gh api repos/<owner>/<repo> --jq '{forks: .forks_count, network: .network_count}'
git ls-remote <url>

Sans fork ni PR, un ramassage côté GitHub finit probablement par l'emporter — sans garantie ni délai annonçable. La seule voie certaine est un ticket au support GitHub, que seul le titulaire du compte peut ouvrir.

Corollaire sur ce qu'on publie : un nom d'hôte, une arborescence de dossiers ou un nom d'organisation repris des vraies données de l'utilisateur n'ont pas leur place dans un document de ce dépôt, qui est public. Un exemple neutre (app.exemple.fr) porte le propos aussi bien. Une fois publié, le retrait n'est jamais complet — c'est tout l'objet de ce piège.

Debug & workflow de dev

#8 — Debug via Chrome DevTools Protocol

Tabby.exe --debug --remote-debugging-port=9333 → connexion Node en WebSocket natif pour lire la vraie console et interroger le DOM. Utile car Tabby avale beaucoup d'erreurs de chargement de plugin dans des catch génériques.

Important

Après toute modification du plugin : tuer et relancer tout le process Tabby.exe — pas un simple reload de page. Le cache de modules Node est global au process.

Atteindre l'état vivant depuis le CDP

Le build de Tabby est en mode production : ngDevMode vaut false et window.ng n'expose pas getComponent(). Sur un élément hôte, __ngContext__ est un simple entier, inexploitable. Le chemin qui marche :

const core = require('@angular/core')                       // require existe dans le renderer
const inj  = core.getPlatform()._modules[0]._r3Injector     // l'injecteur du module racine
const app  = inj.get(core.ApplicationRef)
const cmp  = app._views.map(v => v.context || (v._lView && v._lView[8]))
                       .find(c => c && c.constructor.name === 'SidebarPlusTreeComponent')
const config = inj.get(require('tabby-core').ConfigService) // config.store en clair, malgré le coffre

La sidebar apparaît dans _views parce que SidebarPlusMountService l'attache par appRef.attachView(). C'est aussi la seule façon de lire config.store : le coffre-fort chiffre config.yaml sur disque, mais l'objet vivant, lui, est en clair.

_views ne contient que les vues attachées à ApplicationRef — donc pas un composant enfant d'un template. Deux compléments couvrent le reste :

// une classe de composant declaree par le module, meme sans instance vivante
const Browser = require('tabby-better-sidebar').default.ɵmod.declarations
    .find(d => d.name === 'SidebarPlusSftpBrowserComponent')

// n'importe quel service `providedIn: 'root'` deja instancie, par son nom
let registry = null
inj.records.forEach(rec => {
    const v = rec && rec.value
    if (v && v.constructor && v.constructor.name === 'SidebarPlusTransfersService') { registry = v }
})

Avec la classe, core.createComponent(Browser, { hostElement, environmentInjector }) puis instance.session = … et await instance.ngOnInit() donnent un composant réel branché sur une vraie session — le geste que fait attachPanel(). Il se démonte comme lui : retirer le nœud puis détruire la vue, dans cet ordre.

Deux choses à ne pas déduire de travers en pilotant :

#65 — Piloter un glisser sur une ligne du panneau SFTP gèle le renderer, CDP compris

Le chemin de glisser-déposer sortant appelle webContents.startDrag(), qui ouvre la boucle de messages OLE de Windows : elle attend un vrai geste souris et ne rend la main qu'à son terme. Déclenchée sans souris, elle ne se termine jamais — le renderer ne répond plus, le Runtime.evaluate en cours reste pendant, et les requêtes /json/list finissent par ne plus aboutir non plus. Seul un Stop-Process en sort. Le symptôme se lit comme un blocage du plugin alors que c'est le harnais qui a déclenché une API bloquante.

Deux façons d'y tomber, toutes deux vérifiées le 2026-08-02 :

Pour piloter malgré tout : appeler les handlers à la main (jamais dispatchEvent), avec un objet d'événement fabriqué portant un vrai DataTransfer et l'élément DOM réel comme target — c'est ce que aimFrom() interroge. Et neutraliser le chemin sortant avant de toucher à un dossier : soit config.store.sidebarPlus.sftpDragOutFolders = false en mémoire (aucun config.save(), donc rien n'atteint config.yaml), soit remplacer dragOut.startDrag par une doublure. Tout ce qui touche au dépôt entrant et au déplacement interne se pilote sans précaution.

#66 — Un DataTransfer fabriqué ne dit pas la vérité sur effectAllowed / dropEffect

La spécification ne rend ces deux champs inscriptibles que dans leur phase : effectAllowed pendant dragstart, dropEffect pendant dragenter/dragover. Sur un new DataTransfer() piloté hors d'un vrai glisser, les affectations sont ignorées sans erreur et la lecture rend 'none' — y compris là où le code vient d'écrire 'copyMove' ou 'move'. Une passe de test qui conclut de là que le curseur est faux se trompe de coupable.

Ce qu'une telle passe établit malgré tout, et qui suffit à valider la logique : les types annoncés (dataTransfer.types), l'acceptation de la cible (preventDefault() appelé ou non), les classes portées par les lignes, et l'état réel du serveur après coup. Le curseur, lui, ne se vérifie qu'à la main.

#63 — Un octet de contrôle brut dans un source le rend « binaire » pour rg et grep, qui le sautent sans rien dire

Une chaîne littérale contenant un \0 écrit brut plutôt qu'échappé compile et s'exécute normalement — mais ripgrep et grep classent alors le fichier entier comme binaire et l'excluent de tout résultat, sans message. Un fichier de 1136 lignes est ainsi resté invisible à toute recherche de code jusqu'au 2026-08-02, y compris pour vérifier si une méthode était encore utilisée : la réponse « aucune occurrence » était fausse.

Git, lui, ne s'en aperçoit pas si l'octet est au-delà des 8000 premiers : ses diff restent lisibles, donc rien ne signale le problème côté revue.

→ Toujours écrire ces caractères sous forme échappée ('\0', '\x1b'…). Pour dépister : grep -c "n_importe_quoi" fichier.ts répondant Binary file … matches est le symptôme.

Publication & distribution npm

#72 — Un chunk dynamique dans un plugin Tabby : ce qui le fait marcher, et ce qui le fait publier en double

src/icons.ts charge les collections Iconify par import(), donc webpack émet un second fichier à côté d'index.js. Trois conditions, dont deux ne se voient pas avant qu'il soit trop tard :

#73 — output.clean emporterait les typings du paquet

Tentant pour régler le point précédent, et à ne pas faire : les .d.ts que typings désigne sont écrits directement dans dist/ par TypeScript (declarationDir du tsconfig) et ne passent pas par les assets webpack — vérifiable en construisant vers un autre --output-path, qui ne produit qu'index.js. Un nettoyage les prendrait pour des résidus et publierait un paquet sans typings. Ce que build:prod doit retirer — les source-maps d'un build de développement antérieur — l'est par scripts/strip-source-maps.js, qui ne touche que les .map.

#20 — Classement npm search très bas pour un paquet neuf

Constaté quelques heures après la publication de tabby-better-sidebar@0.2.0 : le paquet est bien indexé (présent dans les résultats du mot-clé tabby-plugin), mais même une recherche par nom exact ne le fait pas apparaître dans les 250 premiers résultats de registry.npmjs.org/-/v1/search — l'algorithme pondère fortement la popularité (téléchargements, maintenance), qu'un paquet neuf n'a pas encore. Pas un bug de publication. Devrait s'améliorer avec l'usage, sans garantie de délai. Contournement pour tester l'install "officielle" en attendant :

cd "$env:APPDATA\tabby\plugins"
npm install tabby-better-sidebar

Le détail de design des fonctionnalités pas encore construites (SFTP, Workspaces, etc.) vit dans ROADMAP.html, avec le reste de chaque chantier — ce fichier ne garde que les invariants du code déjà en place.