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.
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.
config.yaml & persistance — #12, #16, #23, #24, #25, #26, #27, #28, #29, #31, #40, #62, #68, #74, #76, #77 + 2 points de veilletabby-ssh-sidebar (jamais renommé, casserait la
jonction NTFS de dev). Nom réel du package npm / dépôt GitHub : tabby-better-sidebar.
Le loader de plugins Tabby se base sur le nom du dossier dans plugins/node_modules/
(déjà correct), pas sur package.json.name ni le nom du dossier source — incohérence
cosmétique sans impact fonctionnel.LICENSE porte "Copyright (c) 2026 tabby-better-sidebar contributors" —
formule valable en soi, mais le champ author de package.json dit
TooMuhtsh : à aligner avant une release soignée (chantier
Ménage).app.exemple.fr.--global) : TooMuhtsh <188712716+TooMuhtsh@users.noreply.github.com>
(email noreply GitHub du compte perso). À reconfigurer manuellement sur toute nouvelle machine
(non inclus dans ce qui est poussé sur GitHub).Avant même de faire tourner ou de publier le plugin.
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.
TABBY_PLUGINS cassé sur WindowsTabby 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"
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.
package.json sans champ authorLe 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.
tabby-core)typescript@^4.9.x (pas 5.x)pug@^2.0.x (pug-loader@2.4.0 exige pug@^2.0.0)@types/node@^18.19.0 (26.x embarque des typings node:ffi qui ne
parsent pas sous TS 4.9)@ng-bootstrap/ng-bootstrap@^14.x (la dernière vise Angular 18+)npm install échoue sur un postinstall qui compile du natifConstat 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.
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.
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.
templateUrl/styleUrls ne marchent pas pour un plugin tiersRé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.
:host ne fonctionne pas dans le SCSS de ce pluginLe 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.
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.
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).
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 :
sftpPanel.component.scss,
écrit en :host { ... }, s'applique normalement — l'attribut _nghost-…
est posé sur l'élément hôte même quand il est créé par createComponent(). Sa mise en
page interne (colonne flex, > .body { flex: 1 1 0; overflow-y: auto }) est donc
acquise, rien à redéclarer.
Vrai tant qu'on monte SFTPPanelComponent lui-même, ce qui était le cas quand ce
piège a été écrit. Le code monte désormais SidebarPlusSftpBrowserComponent, une
sous-classe portant son propre @Component et son propre template
(sftpBrowser.component.ts, montée par createComponent() depuis
sftpPanel.component.ts). Les règles :host de la classe mère portent
son attribut d'hôte, pas celui de la sous-classe : elles ne s'appliquent donc jamais, et
la mise en page interne est entièrement à notre charge — ce que dit l'en-tête de
sftpBrowser.component.scss. Le reste du piège vaut toujours, et le correctif qu'il
prescrit (fournir le dimensionnement, min-height: 0 sur chaque maillon flex) n'a fait
que devenir plus nécessaire.
sshTab.component.scss
(sftp-panel { position: absolute; height: 80%; width: 100%; bottom: 0; z-index: 5 }),
et cette règle porte l'attribut de contenu de sshTab : elle ne peut pas atteindre une
instance vivant dans la vue d'un autre composant. Greffé dans la sidebar, le panneau arrive donc
sans aucune taille. Le piège n'est pas d'hériter d'un positionnement indésirable
(ça n'arrive pas), c'est de croire qu'un composant qui s'affiche correctement ailleurs se
dimensionnera tout seul ici.→ 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 }.
@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.
(contextmenu) sur l'espace vide de la sidebarDoit ê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).
cdkDropList vide = 0px de hauteurCible 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-'].
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 :empty —
min-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.
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.
cdkDropList derrière *ngIf disparaît complètement de l'enregistrement CDK, pas juste visuellementLes 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.
ngAfterViewChecked(), le DOM contient encore l'élément sortant quand deux *ngIf frères basculent dans le même cycleSymptô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.
attr.x='...' en pug sans crochets n'est pas un binding Angular : l'attribut s'appelle littéralement attr.xLe 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.
await config.save() ne s'exécute pas dans la zone Angular : l'état change, la vue ne se repeint pasConstaté 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.
title détruit l'infobulle native affichée : une valeur
qui se rafraîchit scintille sous le curseurConstaté 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.
providedIn: 'root' que personne n'injecte n'est
jamais construitAngular 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.
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.
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.
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.
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 :
.cdk-drag-placeholder ne sort pas :
DragRef._startDragSequence() fait
parent.replaceChild(placeholder, element) — il prend la place de la ligne dans sa
liste et suit le pointeur d'une liste à l'autre, sans jamais quitter le sous-arbre. Il se cadre
donc par le tag du composant, comme n'importe quelle règle..cdk-drag-preview sort réellement, dans un calque au niveau du
body : aucun sélecteur ancêtre ne l'atteint. La réponse n'est pas d'abandonner mais
cdkDragPreviewClass, l'entrée que CDK expose exactement pour ça — elle pose une
classe à nous sur l'enveloppe que CDK crée, seul élément qu'aucun template ne peut
nommer (le contenu *cdkDragPreview est dedans)..cdk-drag-animating est du côté de l'aperçu, pas de la ligne : CDK la pose sur le
preview dans _animatePreviewToPlaceholder(). La cadrer par le tag ne la restreint
pas, elle la tue — il faut la combiner à la classe d'aperçu.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.
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.
tabby-core en retard sur l'app installéebuildGroupTree(), 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.
startDownload()/startUpload() n'existent pas dans les typings npmCas 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.
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.
webContents.startDrag() exige un fichier déjà présent sur le
disque, et le geste natif fige le rendererDeux contraintes distinctes, qui imposent ensemble la conception en deux temps du glisser-déposer sortant :
startDrag({ file, icon }) veut un chemin local existant au moment
de l'appel, et une NativeImage non vide — nativeImage.createEmpty()
lève une exception sur Windows, l'icône n'est donc pas optionnelle. Le handler
dragstart étant synchrone, il est impossible d'y télécharger quoi que ce soit : le
premier geste prépare une copie, le suivant la fait partir.DoDragDrop) : Tabby paraît figé tant que le bouton est maintenu. C'est
le comportement de la plateforme, pas un défaut du plugin — mais c'est indiscernable d'un blocage,
et il faut le savoir avant de diagnostiquer une lenteur observée pendant un drag.dragstart laissé à Chromium réveille la zone de dépôt de
Tabby, qui lève une exception dans le videLe 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.
startUploadFromDragEvent() ne lit qu'une tranche de chaque
dossier déposé : au-delà, le reste part à la poubelle sans un motPlatformService.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).
SFTPPanelComponent.ngOnInit() appelle openSFTP() hors de
son try/catch : l'échec tue le panneau en silenceDans 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.
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).
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.
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 ».
SFTPSession.download() ne consulte jamais l'annulation : seule
une exception arrête la boucleLa 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é.
<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é.
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 :
open ment. Dans le bundle réellement chargé, le seul
this.open = false est l'initialisation du champ dans le constructeur ;
destroy() se contente de willDestroy.next() puis
ssh.disconnect(). Une session tuée par une coupure de lien se déclare ouverte
aussi longtemps que l'objet vit. Le #37 disait « open reste vrai après un
exit » ; c'est en réalité plus large — il reste vrai après n'importe quelle mort.ConnectableTerminalTabComponent.reconnect() détruit la session shell puis rappelle
initializeSession() sur le même SSHTabComponent : seul
tab.sshSession est remplacé (par un objet neuf — le multiplexeur purge sa map sur
willDestroy$, donc setupOneSession() ne retrouve rien à réutiliser).
Tout code qui compare des onglets pour décider s'il doit se reconstruire ne verra donc
jamais de changement.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.
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.
SFTPSession.stat() perd les permissions et la date : ne
jamais s'en servir pour observer une entréerussh 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.
NotificationsService.notice() impose une seconde d'affichageSon 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.
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.
@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é.
EditProfileModalComponent : cumul des pièges #13 et #6Exporté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).
propName, jamais templateName — la doc d'Angular donne l'exemple à l'enversAssigner 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.
EditProfileModalComponent déclare
@Input('profile') partialProfile : partialProfile est la propriété —
celle qu'on assigne — et profile le nom public, celui d'un binding de template.
Le miroir rend { propName: 'partialProfile', templateName: 'profile' }. Quand on
assigne sur l'instance, c'est propName qu'il faut chercher.@Input('inputName') inputPropName y donne
{propName: 'inputName', templateName: 'inputPropName'}). C'est faux : dans
l'implémentation, invertObject() construit def.inputs indexé par le nom
public, et toRefArray() remet la valeur — donc la propriété — dans
propName. Lire l'implémentation dans le bundle, pas le commentaire au-dessus.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.
tabby-ssh de même numéro de version mais de contenu différentnode_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.
tabby-* : tout autre présent dans le node_modules du plugin est chargé une seconde foisinstanceof 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.
SFTPPanelComponent.getModeString() est faux sous Windows : hériter d'un composant, c'est hériter de ses bugsLa 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.
toLocaleDateString() sans argument rend de l'anglais américain dans ElectronLa 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.
sshSession.open reste vrai après la fin du shell : ce n'est pas le
transport qui dit si une session est vivanteConstaté 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.
<a> dans un <a> est silencieusement
désimbriqué par le parseur HTMLLes 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.
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?.customTitle — parent 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.
@electron/remote est atteignable depuis un plugin, mais pas par un importConstaté 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.
SFTPFile.isDirectory vient d'un champ que le protocole SFTP v3
ne transporte pasTrouvé 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.
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 \\.
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().
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.activeTabVrai @Input() documenté mais toujours @hidden donc non contractuel.
Vérifier que activeTab: 'profiles' amène toujours sur le bon sous-onglet.
SFTPPanelComponent/SFTPSession/SSHTabComponentVé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 :
SFTPPanelComponent.session attend une SSHSession déjà connectée —
c'est SSHTabComponent.sshSession, pas
SSHTabComponent.session (ce dernier est la SSHShellSession du terminal
et n'a pas d'openSFTP()).SSHTabComponent est exportée à l'exécution mais absente des typings npm →
augmentation dédiée src/tabby-ssh-augment.d.ts (même principe que les pièges #6/#17).
SSHSession, elle, n'est pas exportée à l'exécution : son type est emprunté
par accès indexé à SFTPPanelComponent['session'] plutôt que redéclaré — une
redéclaration serait nominalement incompatible à cause de ses membres private.SSHModule déclare SFTPPanelComponent mais ne l'exporte
pas : son sélecteur <sftp-panel> est donc invisible depuis le template
d'un autre module. D'où le montage impératif par createComponent(), qui contourne
entièrement la portée des templates.SFTPSession n'a pas de close() — un canal SFTP ne meurt qu'avec sa
session SSH. Toute reconstruction du panneau ouvre donc un canal de plus, définitivement : d'où
le cache d'un panneau par onglet dans SidebarPlusSftpComponent.SFTPFile reste une simple interface (type only, normalement absente du
bloc d'export runtime) ; SFTPFileHandle n'est pas réexporté au niveau de l'index.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-03Le 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.
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.
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.
options.password, un mot de passe réellement stocké dans le profil et lu par
ssh.ts. Chiffré au repos quand le coffre est actif, mais bien présent dans l'objet.{user, host, port} s'authentifie donc tout seul.
Rien ne fuit, mais un profil « purgé » n'est pas muet pour autant.VaultService.getSecret() retente sans le host quand la
clé complète ne donne rien (commentaire d'origine : un mot de passe par défaut partagé entre
serveurs). Un secret enregistré pour un utilisateur couvre alors n'importe quel hôte.privateKeys[], qui peut contenir
vault://<id> (VaultFileProvider). Opaque et inutile ailleurs, y
compris sur une autre machine du même utilisateur : la garder donne un profil qui échoue à trouver
sa clé sans rien dire.config.yaml & persistanceLa catégorie la plus critique du projet — a réellement corrompu des données de production une fois.
getProfileGroups() ne clone pas profondément — pollue config.yamlA 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.
ConfigProvider.defaults doit déclarer chaque clé individuellementUne 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.
config.store.sidebarPlus.X sans réassignation explicite ne persiste jamaisPas 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.
cdkDropListConnectedTo qui matche, et un conteneur ancêtre matche toujoursSymptô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.
cdkDropList en réaction au glisser est doublement casséeTentative « 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.
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.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).
: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.
: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.
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.
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.
cdkDragEnded est émis avant cdkDropListDroppedDans 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.
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 :
workspaceOrder?.length, c'est-à-dire à la
liste entière : une liste ne contenant que des ids morts reste « non vide » et supprimait
donc complètement le repli sur l'ordre de « Tous », envoyant tout en tri alphabétique. Le repli
doit se résoudre élément par élément (un dossier absent de la liste du workspace
reprend sa position dans « Tous »), ce que décrit la roadmap.onProfileDrop(). Sans ça l'id reste indéfiniment dans la liste d'ordre de son ancien
parent (constaté : un même id listé à la fois sous root et sous le dossier dans lequel
il venait d'être imbriqué).→ 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).
display: none) casse le pointage et le cache de CDKTentative (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 :
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._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.
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 :
sidebarPlus.groupOrder et ws.groupOrder — comme valeur dans une
liste de fratrie et comme clé du parent (d'où
renameOrderKey(), qui fait les deux) ;ws.hiddenGroupIds ;ws.favoriteGroups et sidebarPlus.favoriteGroups — celui du
niveau supérieur porte les favoris du workspace « Tous », qui n'a pas d'entrée dans
workspaces ; c'est celui qu'on oublie ;localStorage.sidebarPlusGroupCollapsed — hors de config.yaml, donc hors
du champ de vision quand on relit le code de persistance.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.
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.
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 :
persistProfileOrder() tombe sur
persistProfileWeights() et écrit les weight natifs des profils —
l'ordre de la recherche devient l'ordre réel du dossier, dans config.yaml, sans un
mot ;profileOrder sous l'id search, qui ne
correspond à aucun dossier et que plus rien ne relit — une clé morte, sans dégât.→ [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.
Get-Content/Set-Content -Encoding utf8 (PS 5.1) corrompt les accents de config.yamlUn 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.
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.
platform.startDownload()
✅ Deux appuis sur trois retirés le 2026-08-03Depuis 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 :
panel.downloadItem(item) » — sans objet.
La surcharge est passée de downloadItem() à download(), où
les deux voies aboutissent, open() comprise. Que Tabby rebranche son entrée
sur l'une ou sur l'autre ne change plus rien, et downloadItem() est redevenue la
méthode héritée. Il n'y avait rien à surveiller ici, seulement un appui à déplacer.will-download » — demandé, plus
supposé. C'est ce qui laisse Electron ouvrir sa boîte « Enregistrer sous » ; un
gestionnaire imposerait la destination, ou annulerait le téléchargement — un clic sans effet.
listenerCount('will-download') est consulté avant chaque téléchargement (méthode
d'EventEmitter, donc réellement contractuelle, ce que rien d'autre n'est ici) : s'il
y en a un, la voie héritée reprend la main, puisque c'est elle que Tabby s'attendrait à gérer.
Mesuré à 0 sur l'app installée.Content-Disposition: attachment reste ce qui fait télécharger plutôt que naviguer —
mais c'est notre propre serveur qui l'écrit : ce n'est pas un appui sur l'hôte.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.
getProfileGroups()/buildGroupTree()
✅ Point unique + sonde depuis le 2026-08-03Le 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é.
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éesconfig.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.
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.
defaults d'un dossier est un bloc par fournisseur :
ses champs sont un cran plus bas qu'on ne les chercheSa 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.
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.
gh auth login authentifie le compte actif du navigateurPas un compte choisi dans le CLI. Vérifier le compte actif dans le navigateur avant, ou
gh auth status --active après.
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}').
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é.
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).
git rev-list --reverse, et il faut un vrai outil de mapping.git diff refs/original/refs/heads/master master doit être
vide — c'est le contrôle qui prouve qu'on n'a touché qu'aux messages. Il ne vaut
que pour une purge de messages : une purge de contenu (--tree-filter) change
l'arbre par construction, et le contrôle qui la prouve est l'absence d'occurrence dans
tous les commits, pas seulement au HEAD.filter-branch
écrit sa map dans un répertoire temporaire qu'il efface en terminant — .git/filter-branch/map
est introuvable quand on en a besoin. Elle se reconstitue depuis refs/original, qui
survit, en appariant les deux listes rev-list --topo-order --reverse. Apparier sur la
position et vérifier sujet et date à chaque rang : sans ce contrôle, un décalage
d'un seul cran réécrirait toute la documentation avec des hashs faux mais plausibles.<hash>^{commit}.diff nu contre la charte canonique signale une divergence
totale alors que le contenu versionné est identiquecore.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.
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.
push --force ne retire rien de GitHubRéé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.
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.
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.
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.
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 :
NgbModal.activeInstances émet à l'intérieur de open(),
donc avant que l'appelant ait affecté les @Input() de la modale. Lire
componentInstance.message dans l'abonnement rend undefined et les valeurs
par défaut — ce qui se lit comme un défaut du code observé alors que c'est la sonde qui est en
avance. Lire après un tour de boucle d'événements.ref.changeDetectorRef.detectChanges(), sa template n'a produit aucun DOM, et toute
recherche de ligne dans son hôte rend vide.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 :
element.dispatchEvent(new DragEvent('dragstart', …)) sur une ligne
draggable ;onDragStart() directement sur l'instance quand une copie locale est
déjà prête — startDrag() part alors dans la foulée. Le cas se déclenche aussi
au premier geste sur un dossier, la copie étant téléchargée puis remise tout de suite si
gestureHeld est vrai, ce qu'un pilotage ne dément jamais.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.
DataTransfer fabriqué ne dit pas la vérité sur
effectAllowed / dropEffectLa 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.
rg et grep, qui le sautent sans rien direUne 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.
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 :
require("./<chunk>.js"), relatif au
bundle (cible node). Cela fonctionne parce que le chargeur de plugins de Tabby fait
nodeRequire = global['require'] puis nodeRequire(chemin_du_plugin) —
un require CommonJS ordinaire, résolu depuis dist/. À revérifier si ce
chargeur change : un chargement par vm ou par lecture de fichier casserait le chemin
relatif, et le symptôme n'apparaîtrait qu'à la première ouverture du sélecteur d'icônes.chunkIds: 'named' est forcé, y compris en production, où webpack
numérote les chunks par défaut. Le nom changeant alors avec le mode, dist/ — qui
n'est pas nettoyé entre deux builds (voir ci-dessous) — garde les deux, et
files: ["dist"] publie 5 Mo d'icônes en double sans un mot.devDependencies et doivent le rester :
le chunk les embarque, et une installation utilisateur n'a pas de
node_modules/@iconify-json/*. Le poids du paquet ne change donc pas — ce qui tombe,
c'est le coût de démarrage, le bundle que Tabby parse à chaque lancement (5,33 → 0,26 Mo en
production).output.clean emporterait les typings du paquetTentant 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.
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.