From 61347e0f0bdd5aa703450cc9065573cdf78915b8 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 17 Sep 2026 08:48:00 -0400 Subject: [PATCH] fix(forge): autocompletion Tab naturelle et fiable (BUG-055) --- .gitea/workflows/ci.yml | 4 +- CHANGELOG.md | 25 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/ai.py | 9 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 2 +- frontend/editor-poc.html | 279 ++++++++++++++--------- frontend/js/autocomplete.js | 95 +++++++- package.json | 2 +- tests/frontend/forge-completion.test.mjs | 207 +++++++++++++++++ 15 files changed, 524 insertions(+), 121 deletions(-) create mode 100644 tests/frontend/forge-completion.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e6ba982..b0186be 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,7 +36,9 @@ jobs: run: node tests/frontend/validate-imports.mjs - name: Frontend unit tests - run: node tests/frontend/unit.test.mjs + run: | + node tests/frontend/unit.test.mjs + node tests/frontend/forge-completion.test.mjs - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition) run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de53a3..66a8c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.8.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.8.1**. --- @@ -14,6 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.8.1] — 2026-09-17 + +### Corrigé + +- **BUG-055 - Éditeur Forge : autocomplétion Tab naturelle et fiable** : la touche `Tab` + déclenchait simultanément trois actions indépendantes (indentation, complétion d'un mot du + document, acceptation de la prédiction IA), ce qui insérait un ou deux espaces avant le mot + complété — et le retour arrière effaçait alors l'ajout. La gestion de `Tab` est désormais + **unifiée** avec une priorité claire (liste de suggestions ouverte → prédiction IA → + complétion de mot du document → indentation), une seule action par appui. Les helpers de + complétion sont extraits en fonctions pures partagées avec CodeMirror + (`getWordFragment`, `findWordCompletions`, `normalizeGhost`, `chooseTabAction`) ; plusieurs + candidats affichent une liste positionnée au curseur ; la **complétion fantôme** n'affiche + plus un miroir transparent de tout le document (source du décalage visuel et des espaces + fantômes) mais uniquement la prédiction, positionnée exactement au curseur et nettoyée dès + que le curseur bouge ou que la vue défile ; une complétion de mot ne peut plus introduire + d'espace. Le prompt `/api/ai/inline-complete` est simplifié et borné à 128 tokens pour des + suggestions plus courtes et plus rapides. Fichiers : `frontend/editor-poc.html`, + `frontend/js/autocomplete.js`, `backend/ai.py`. Tests : + `tests/frontend/forge-completion.test.mjs` (nouveau, 28 tests). + +--- + ## [2.8.0] — 2026-09-17 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 2dff347..b049701 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.8.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.8.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.8.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.8.1). --- -*Projet : ObsiGate | Version : 2.8.0 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.8.1 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 972c0b3..93a7d4c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.8.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.8.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.8.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.8.1). --- -*Project: ObsiGate | Version: 2.8.0 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.8.1 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 834f262..dbe5900 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.0 +2.8.1 diff --git a/backend/ai.py b/backend/ai.py index 036e5f9..b9e8da4 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -353,10 +353,13 @@ async def ai_generate_frontmatter(text: str, provider: ProviderName | None = Non async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str: - """Inline completion — suggest continuation.""" + """Inline completion — suggest a short continuation of the text before the cursor.""" return await _call_deepseek_openrouter( - f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}", - SYSTEM_PROMPT, provider, temperature=0.3, max_tokens=512, + "Continue the text below in the same language. Reply with ONLY the " + "continuation: no repetition, no quotes, no explanation, at most one " + "short sentence. If the text ends with a partial word, finish that word.\n\n" + + text, + SYSTEM_PROMPT, provider, temperature=0.2, max_tokens=128, ) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 54a6cae..ff226dc 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.8.0" +version = "2.8.1" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 7d44cfd..364b319 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.8.0" +version = "2.8.1" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 536fcd9..61bac9f 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.8.0", + "version": "2.8.1", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 3011214..6f1cd68 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -164,6 +164,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-052* | [🟡 IMPORTANT] Assistant IA : recherche web sans réponse finale (10 étapes + sources affichées, aucun texte dans la conversation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `tests/test_agent_loop.py` | Assistant (mode agent) : recherche web qui enchaîne 10 étapes puis n'affiche aucune réponse | `backend/agent/loop.py` : `_finalize_answer` — dernier appel LLM sans outil (instruction de synthèse) quand le budget d'itérations/quota est épuisé, repli déterministe `_fallback_summary` (liste des sources), résultats `deferred` pour les appels non atteints du lot en quota | Cause : `content=""` renvoyé sur `STOP_MAX_ITERATIONS`/`STOP_QUOTA_EXCEEDED` alors que le modèle appelait encore des outils. Tests : `tests/test_agent_loop.py` (+2) | | *BUG-053* | [🟡 IMPORTANT] Assistant IA (mode agent) : le fichier demandé n'est pas créé — le modèle émet un bloc texte `obsigate-action` au lieu d'appeler l'outil `create_file` | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/bookslm.py`, `backend/bookslm_routes.py`, `tests/test_bookslm.py` | Mode agent, contexte Général (ou dossier vide) : « créer le fichier TestVault/sport/… avec le tableau des 84 matchs » → réponse avec un bloc ```obsigate-action``` tronqué, aucun fichier | `backend/bookslm.py` : protocole d'action scindé — `GENERAL_ACTION_TOOL_PROTOCOL` (outils natifs, interdiction des blocs `obsigate-action`) utilisé quand `agent=True`, protocole texte conservé pour le chat classique ; `backend/bookslm_routes.py` : `_resolve_system_prompt(..., agent=True)` depuis l'endpoint agent + règle « Mode agent » pour les prompts dossier/documents, `max_tokens` agent 4096 → 8192 (contenu de fichier complet) | Cause : le prompt Général enseignait encore le protocole texte alors que l'agent dispose du function calling. Tests : `tests/test_bookslm.py` (+3) | | *BUG-054* | [🟡 IMPORTANT] Éditeur « Editer » : le bouton Sauvegarder reste bloqué sur le spinner de chargement (retour au crochet uniquement après un refresh complet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/utils.js` | Ouvrir un fichier → Editer → cliquer Sauvegarder (ou Ctrl+S) ; rouvrir l'éditeur : le bouton reste un spinner désactivé | Nouveau helper `resetSaveButton()` (crochet `✓` + `disabled=false` + styles en ligne nettoyés) appelé à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas d'échec (`saveFile`). Tests : `tests/frontend/editor-inline.test.mjs` (+4) | Le nœud `#editor-save` est partagé entre sessions : l'état « spinner + désactivé » posé par une sauvegarde manuelle n'était jamais remis à zéro (succès → fermeture puis réouverture, Forge, ou échec réseau dans le `catch`). Seul un rechargement de `index.html` restaurait le crochet | +| *BUG-055* | [🟡 IMPORTANT] Éditeur Forge : l'autocomplétion (Tab) ajoute des espaces parasites, l'effacement détruit le mot complété et la complétion fantôme est illisible | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau) | Forge : taper un mot, puis Tab pour compléter ; un espace (voire deux) s'insère avant le mot complété, et le retour arrière efface l'ajout. La prédiction IA s'affichait décalée (texte miroir du document entier) | **Cause** : trois gestionnaires `keydown` Tab indépendants s'exécutaient tous — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot et à l'acceptation du ghost. **Correctif** : gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`, une seule action), helpers purs partagés (`getWordFragment`, `findWordCompletions`, `normalizeGhost`, `chooseTabAction`) dans `autocomplete.js`, liste déroulante si plusieurs candidats, dropdown positionné au curseur, ghost **positionné au curseur** (fini le miroir du document, nettoyé au déplacement/scroll), complétion de mot sans espace garanti (`normalizeGhost` tronque au premier espace) et prompt `/api/ai/inline-complete` simplifié. Tests : `tests/frontend/forge-completion.test.mjs` (28). | Cause du bug : l'indentation Tab n'était pas conditionnée à l'absence de suggestion. Le ghost re-rendait tout le texte transparent + prédiction, d'où l'impression d'espaces et les erreurs d'effacement | | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -220,6 +221,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-16 | BUG-053 | Correction | `backend/bookslm.py`, `backend/bookslm_routes.py`, `tests/test_bookslm.py`, `CHANGELOG.md` | **BUG-053** : en mode agent, le prompt Général (et dossier vide) enseignait le protocole texte `obsigate-action` ; le modèle décrivait donc l'action au lieu d'appeler l'outil natif `create_file` (bloc volumineux de surcroît tronqué avant fermeture → aucun fichier créé). Le prompt est scindé : `GENERAL_ACTION_TOOL_PROTOCOL` (appel direct des outils natifs, interdiction explicite des blocs `obsigate-action`) pour `build_general_system_prompt(agent=True)`, le protocole texte restant utilisé par le chat classique ; `_resolve_system_prompt` propage `agent` et ajoute une règle « Mode agent » aux prompts dossier/documents ; `max_tokens` de l'agent porté à 8192 pour un contenu de fichier complet. Vérifié : `tests/test_bookslm.py` 68/68 (+3 : prompt agent sans protocole texte, prompt classique inchangé, prompt système de l'endpoint agent), suite complète 1087 passed / 6 skipped (14 erreurs MCP préexistantes), ruff 0 (backend), mypy 0 (`backend/bookslm.py`, `backend/bookslm_routes.py`). | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-054 | Correction | `frontend/js/utils.js`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-054** : le bouton `#editor-save` (nœud partagé entre toutes les sessions d'édition) restait bloqué sur le spinner de chargement et désactivé — une sauvegarde manuelle (clic ou Ctrl+S) remplaçait le crochet par le loader et ne le restaurait jamais : succès (l'éditeur se ferme, la réouverture réaffichait le spinner), sauvegarde Forge, ou échec réseau (le `catch` ne restaurait ni l'icône ni l'état). Nouveau helper `resetSaveButton()` (crochet `✓`, `disabled=false`, styles en ligne nettoyés) appelé à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas d'échec (`saveFile`). Vérifié : `tests/frontend/editor-inline.test.mjs` 29/29 (+4), validate-imports 38 modules / 0 erreur. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | #101 | Feature | `frontend/editor-poc.html`, `frontend/js/sync.js`, `frontend/js/viewer.js`, `frontend/js/utils.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/editor-inline.test.mjs`, `docs/features/forge-assistant.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#101** : le bouton « AI Panel » de Forge ouvre désormais l'**Assistant IA** partagé (`postMessage forge-open-ai` → `bookslm.openForCurrentContext()`) au lieu du mini-chat isolé (supprimé) ; Forge lit `localStorage['obsigate_ai_picker']` (`aiPickerSelection()`) pour ses appels `/api/ai/*` et sa complétion fantôme (repli `ollama`), endpoints corrigés (`make-longer`/`make-shorter`, `target_lang`) ; bouton **plein écran** natif ajouté à Forge (iframe `allow="fullscreen"`) et à Editer (`#editor-fullscreen`, conteneur `#editor-container`, sortie à la fermeture, Échap laissé au navigateur) ; i18n `editor.fullscreen`/`editor.exit_fullscreen`. Vérifié : `tests/frontend/editor-inline.test.mjs` 40/40 (+10), unit 9/9, validate-imports 38 modules, 13 suites JSDOM vertes. | 🟢 livré (en attente vérif utilisateur) | +| 2026-09-17 | BUG-055 | Correction | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-055** : trois gestionnaires `keydown` Tab indépendants s'exécutaient à chaque appui — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot (`insertAtCursor(suffixe)`) et à l'acceptation du ghost text, d'où l'espace parasite avant le mot complété puis un effacement destructeur. Gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`), helpers purs partagés (`getWordFragment`/`findWordCompletions`/`normalizeGhost`/`chooseTabAction`) extraits dans `autocomplete.js`, liste déroulante au curseur quand plusieurs mots correspondent, ghost **positionné au curseur** (plus de miroir du document entier, nettoyé au déplacement/scroll), complétion de mot sans espace garantie et prompt `/api/ai/inline-complete` simplifié (128 tokens). Vérifié : `tests/frontend/forge-completion.test.mjs` 28/28 (nouveau), `unit.test.mjs` 9/9, `editor-inline.test.mjs` 40/40, `ai.test.mjs` 88/88, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 60a7362..00a3d49 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.8.0 | **Dernière mise à jour :** 2026-09-17 +> **Version :** 2.8.1 | **Dernière mise à jour :** 2026-09-17 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** diff --git a/frontend/editor-poc.html b/frontend/editor-poc.html index 98cb99c..bec209a 100644 --- a/frontend/editor-poc.html +++ b/frontend/editor-poc.html @@ -212,18 +212,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .ac-item-label { font-family: var(--mono); font-weight: 500; white-space: nowrap; } .ac-item-detail { font-size: 10.5px; color: var(--text3); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; } .ac-item-context { font-size: 9px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; padding: 2px 10px 4px; } -/* Ghost text overlay */ +/* Ghost text overlay (AI inline prediction, positioned at the caret) */ .ghost-overlay { - position: absolute; top: 0; left: 0; right: 0; bottom: 0; + position: absolute; top: 0; left: 0; pointer-events: none; z-index: 1; - padding: 24px 16px 24px 12px; font-family: var(--mono); font-size: 13.5px; line-height: 1.75; - white-space: pre-wrap; word-wrap: break-word; - overflow: hidden; color: transparent; -} -.ghost-prediction { - color: var(--text3); opacity: 0.5; + white-space: pre; color: var(--text3); opacity: 0.55; } +.ghost-prediction { color: inherit; } @@ -390,7 +386,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig /Menu de commandes (en debut de ligne) Alt+IInsertion rapide (Quick Insert) F1Ce panneau d'aide - TabIndenter la ligne / element de liste + TabValider la suggestion / completer le mot / indenter Shift+TabDesindenter

Formatage

@@ -480,6 +476,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig var saveTimer = null; var aiConfigured = null; + // Shared autocomplete helpers (pure functions from autocomplete.js). Loaded + // once at startup so the « Tab » handler can use them synchronously. + var ac = null; + import('/static/js/autocomplete.js').then(function(m) { ac = m; }).catch(function() {}); + // Parse URL params (function() { var p = new URLSearchParams(window.location.search); @@ -853,7 +854,27 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig })(); // ---- Caret position to pixel (for monospace textarea) ---- - function caretToPixel(pos) { + // Measured once so the caret math matches the actual font metrics. + var _charW = 0; + function getCharWidth() { + if (_charW) return _charW; + var style = getComputedStyle(ta); + var span = document.createElement('span'); + span.style.fontFamily = style.fontFamily; + span.style.fontSize = style.fontSize; + span.style.fontWeight = style.fontWeight; + span.style.fontStyle = style.fontStyle; + span.style.position = 'absolute'; + span.style.visibility = 'hidden'; + span.style.whiteSpace = 'pre'; + span.textContent = '0123456789'; + document.body.appendChild(span); + _charW = (span.getBoundingClientRect().width / 10) || (parseFloat(style.fontSize) * 0.615); + span.remove(); + return _charW; + } + + function caretToPixel(pos, raw) { if (pos == null) pos = getPos().s; var v = val(); var before = v.substring(0, pos); @@ -864,20 +885,40 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig var fontSize = parseFloat(style.fontSize); var lineH = parseFloat(style.lineHeight); if (isNaN(lineH)) lineH = fontSize * 1.75; - var charW = fontSize * 0.615; + var charW = getCharWidth(); var wrapRect = editorWrap.getBoundingClientRect(); var taRect = ta.getBoundingClientRect(); - // Position relative to editorWrap - var x = taRect.left - wrapRect.left + 2 + colNum * charW; + // Position relative to editorWrap (top of the line below the caret). + var x = taRect.left - wrapRect.left + colNum * charW; var y = taRect.top - wrapRect.top + (lineNum + 1) * lineH - ta.scrollTop; - // Clamp - if (x < 4) x = 4; - if (x > wrapRect.width - 310) x = wrapRect.width - 310; - if (y > wrapRect.height - 360) y = wrapRect.height - 360; - if (y < 20) y = 20; + if (!raw) { + // Clamp for the popup menus (never the inline ghost, which is exact). + if (x < 4) x = 4; + if (x > wrapRect.width - 310) x = wrapRect.width - 310; + if (y > wrapRect.height - 360) y = wrapRect.height - 360; + if (y < 20) y = 20; + } return { x: x, y: y }; } + // Position of the caret itself (same line), used by the inline ghost text. + function caretLinePixel(pos) { + if (pos == null) pos = getPos().s; + var v = val(); + var before = v.substring(0, pos); + var lineNum = before.split('\n').length - 1; + var colNum = pos - v.lastIndexOf('\n', pos - 1) - 1; + if (colNum < 0) colNum = 0; + var style = getComputedStyle(ta); + var lineH = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.75; + var wrapRect = editorWrap.getBoundingClientRect(); + var taRect = ta.getBoundingClientRect(); + return { + x: taRect.left - wrapRect.left + colNum * getCharWidth(), + y: taRect.top - wrapRect.top + lineNum * lineH - ta.scrollTop + }; + } + // ---- Slash menu ---- function initSlashItems() { slashItems = Array.from(slashMenu.querySelectorAll('.slash-item')); } @@ -1275,11 +1316,6 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig if (e.ctrlKey && e.key === 'b') { e.preventDefault(); wrapSelection('**'); } if (e.ctrlKey && e.key === 'i') { e.preventDefault(); wrapSelection('*'); } if (e.ctrlKey && e.key === 'k') { e.preventDefault(); promptLink(); } - // Tab: indent list items - if (e.key === 'Tab') { - e.preventDefault(); - if (e.shiftKey) { dedentLine(); } else { indentLine(); } - } // Quick Insert: Alt+I if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); } }); @@ -1502,10 +1538,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig if (lnGutter) { document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop; } - // Sync ghost overlay scroll - if (ghostOverlay) { - ghostOverlay.scrollTop = editorWrap.scrollTop; - } + clearGhost(); }); // Update line numbers on input @@ -1524,32 +1557,61 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig if (e.key === 'Escape' && helpOverlay.classList.contains('on')) { helpOverlay.classList.remove('on'); } }); - // ---- Autocomplete (Tab completion from existing words) ---- - var _acTimer = null; + // ---- Autocomplete : gestion unifiée de la touche Tab ---- + // Priorité : liste ouverte > prédiction IA (ghost) > complétion de mot du + // document > indentation. Une seule action par appui (avant ce correctif, + // l'indentation s'exécutait *en plus* de la complétion et ajoutait des espaces). ta.addEventListener('keydown', function(e) { - if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !slashVisible && !qiMenu.classList.contains('on')) { - var p = getPos(); - if (p.s !== p.e) return; // Don't autocomplete when selection exists (let indent handle it) - var v = val(); - // Find the word fragment before cursor - var start = p.s; - while (start > 0 && /[\w\-\.\/]/.test(v.charAt(start - 1))) start--; - var fragment = v.substring(start, p.s); - if (fragment.length < 2) return; // Need at least 2 chars - // Find matching words in the document - var re = new RegExp('\\b' + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\w\\-\\.\\/]+', 'gi'); - var matches = []; - var m; - while ((m = re.exec(v)) !== null) { - if (matches.indexOf(m[0]) === -1) matches.push(m[0]); - } - if (matches.length === 1) { - e.preventDefault(); - insertAtCursor(matches[0].substring(fragment.length), 0); + if (e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey) return; + if (slashVisible || qiMenu.classList.contains('on')) return; // ces menus gèrent Tab + + // 1. Liste d'autocomplétion ouverte → valider l'élément surligné + if (acDropdown.classList.contains('active')) { + e.preventDefault(); + if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]); + return; + } + + // 2. Prédiction IA affichée → l'accepter + if (_ghostText) { + e.preventDefault(); + acceptGhost(); + return; + } + + // 3. Complétion à partir des mots du document + var p = getPos(); + if (!e.shiftKey && p.s === p.e && ac) { + var frag = ac.getWordFragment(val(), p.s); + if (frag.fragment.length >= 2) { + var candidates = ac.findWordCompletions(val(), p.s, frag.fragment, 8); + var action = ac.chooseTabAction({ candidates: candidates }); + if (action === 'word') { + e.preventDefault(); + insertAtCursor(candidates[0].slice(frag.fragment.length), 0); + return; + } + if (action === 'word-list') { + e.preventDefault(); + showWordCompletions(candidates, frag); + return; + } } } + + // 4. Défaut : indenter / désindenter + e.preventDefault(); + if (e.shiftKey) { dedentLine(); } else { indentLine(); } }); + // Suggestion list for several document words sharing the typed prefix. + function showWordCompletions(words, frag) { + var items = words.map(function(w) { + return { label: w, detail: 'mot du document', insert: w.slice(frag.fragment.length), kind: 'word' }; + }); + showAutocomplete(items, 'Mots du document'); + } + // ---- Title sync: first heading line <-> title bar (NO rename) ---- function syncTitleFromContent() { var v = val(); @@ -1651,13 +1713,24 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig acItems = items; acIdx = -1; if (!items.length) { hideAutocomplete(); return; } - // Position: centered below the textarea (simple, reliable) - var rect = ta.getBoundingClientRect(); - acDropdown.style.left = (rect.left + rect.width / 2) + 'px'; - acDropdown.style.top = (rect.bottom + 6) + 'px'; - acDropdown.style.transform = 'translateX(-50%)'; - // Keep dropdown within viewport - acDropdown.style.maxWidth = Math.min(380, rect.width - 32) + 'px'; + // Position: just below the caret, clamped to the viewport (natural place + // for a completion list instead of a fixed centered dropdown). + var pt = caretToPixel(ta.selectionStart, true); + var wr = editorWrap.getBoundingClientRect(); + var width = Math.min(380, Math.max(220, editorWrap.clientWidth - 32)); + var estH = Math.min(items.length * 26 + 30, 260); + var left = wr.left + pt.x; + var top = wr.top + pt.y + 4; + if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8; + if (left < 8) left = 8; + if (top + estH > window.innerHeight - 8) { + top = Math.max(8, wr.top + pt.y - estH - 2); + } + acDropdown.style.width = width + 'px'; + acDropdown.style.maxWidth = width + 'px'; + acDropdown.style.left = left + 'px'; + acDropdown.style.top = top + 'px'; + acDropdown.style.transform = 'none'; var html = context ? '
' + context + '
' : ''; items.forEach(function(item, i) { @@ -1698,6 +1771,23 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig function applyAutocomplete(item) { if (!item || !item.insert) return; + + // Document word completions are inserted plainly at the caret — no + // context-aware replacement (which would wipe the line in frontmatter). + if (item.kind === 'word') { + var wp = getPos(); + ta.value = val().slice(0, wp.s) + item.insert + val().slice(wp.e); + var wnp = wp.s + item.insert.length; + ta.setSelectionRange(wnp, wnp); + hideAutocomplete(); + clearGhost(); + ta.focus(); + markDirty(); + autoHeight(); + updateLineNumbers(); + return; + } + var start = ta.selectionStart; var end = ta.selectionEnd; var before = ta.value.slice(0, start); @@ -1727,6 +1817,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig } hideAutocomplete(); + clearGhost(); ta.focus(); markDirty(); autoHeight(); @@ -1768,15 +1859,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig function clearGhost() { _ghostText = ''; - ghostOverlay.innerHTML = ''; + if (ghostPred) ghostPred.textContent = ''; } function showGhost(prediction) { if (!prediction) { clearGhost(); return; } _ghostText = prediction; - var before = ta.value.slice(0, ta.selectionStart); - // Show: existing text (transparent) + prediction (visible faded) - ghostOverlay.innerHTML = escHtml(before) + '' + escHtml(prediction) + ''; + // Only the prediction is rendered, positioned exactly at the caret: no + // mirror of the whole document, so no misalignment and no phantom spaces. + ghostPred.textContent = prediction; + var pt = caretLinePixel(ta.selectionStart); + ghostOverlay.style.left = pt.x + 'px'; + ghostOverlay.style.top = pt.y + 'px'; } function acceptGhost() { @@ -1784,9 +1878,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig var start = ta.selectionStart; var before = ta.value.slice(0, start); var after = ta.value.slice(start); - // Trim to avoid double spaces var insert = _ghostText.replace(/^\s+/, ''); - if (!insert) return; + if (!insert) { clearGhost(); return; } ta.value = before + insert + after; ta.setSelectionRange(start + insert.length, start + insert.length); clearGhost(); @@ -1797,32 +1890,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig } function requestGhostCompletion() { + if (acDropdown.classList.contains('active')) return; // list open: don't compete + if (ta.selectionStart !== ta.selectionEnd) return; // no prediction over a selection var fullText = ta.value.slice(0, ta.selectionStart); - if (fullText.trim().length < 1) return; + if (!fullText.trim()) return; var lastChar = fullText.slice(-1); - var midWord = /[a-zA-Z0-9\u00C0-\u024F]$/.test(lastChar); + var midWord = /[\w\u00C0-\u024F]$/.test(lastChar); + var inputText = midWord ? (fullText.match(/([\w\u00C0-\u024F]+)$/) || [''])[0] : fullText; - // Get user's preferred language - var lang = 'fr'; - try { lang = localStorage.getItem('obsigate-lang') || 'fr'; } catch(e) {} - var langNames = { fr: 'French', en: 'English', es: 'Spanish', de: 'German' }; - var langName = langNames[lang] || 'French'; - - var prompt, inputText; - if (midWord) { - var wordMatch = fullText.match(/([\w\u00C0-\u024F]+)$/); - var partialWord = wordMatch ? wordMatch[1] : fullText; - inputText = partialWord; - var context = fullText.slice(0, -partialWord.length).trim(); - prompt = 'Complete this ' + langName + ' word. The word is: "' + partialWord + - '". Context: "' + (context || '(start of line)') + - '". Return ONLY the remaining letters. Do NOT add spaces. Example: "famil" → "ial" for "familial".'; - } else { - inputText = fullText; - prompt = 'Continue this ' + langName + ' text naturally. Return ONLY the new text (do NOT repeat):\n' + fullText; - } - - var ghostBody = { text: prompt }; + // The backend turns this text into a short « continue this text » prompt. + var ghostBody = { text: fullText }; // #101 — follow the AI Assistant's configured provider/model; fall back to // the local Ollama model when the assistant has no explicit selection. var ghostPick = aiPickerSelection(); @@ -1836,19 +1913,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig }) .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function(data) { - var raw = (data.result || '').trim(); - if (!raw || raw.length < 1) return; - var prediction = raw; - if (midWord && prediction.toLowerCase().startsWith(inputText.toLowerCase())) { - prediction = prediction.slice(inputText.length); - } else if (!midWord && prediction.startsWith(inputText)) { - prediction = prediction.slice(inputText.length); - } - prediction = prediction.trim(); + var prediction = ac + ? ac.normalizeGhost(data.result || '', inputText, midWord) + : String(data.result || '').trim(); + if (!prediction) return; + // Don't suggest text that is already present after the cursor. var alreadyThere = ta.value.slice(ta.selectionStart).trimStart(); - if (prediction && prediction.length > 0 && !alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) { - showGhost(prediction); - } + if (alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) return; + // Ignore a stale response if the caret moved while we were waiting. + if (ta.selectionStart !== ta.selectionEnd) return; + showGhost(prediction); }) .catch(function() { /* silent */ }); } @@ -1860,15 +1934,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig _ghostTimer = setTimeout(requestGhostCompletion, 600); }); + // Clear the prediction as soon as the caret moves elsewhere. + document.addEventListener('selectionchange', function() { + if (document.activeElement === ta) clearGhost(); + }); + function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } ta.addEventListener('keydown', function(e) { - // Tab: accept ghost text prediction - if (e.key === 'Tab' && _ghostText && !acDropdown.classList.contains('active')) { - e.preventDefault(); - acceptGhost(); - return; - } // Escape: clear ghost text if (e.key === 'Escape' && _ghostText && !acDropdown.classList.contains('active')) { clearGhost(); @@ -1877,7 +1950,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig if (acDropdown.classList.contains('active')) { if (e.key === 'ArrowDown') { e.preventDefault(); acIdx = Math.min(acIdx + 1, acItems.length - 1); highlightAcItem(); } else if (e.key === 'ArrowUp') { e.preventDefault(); acIdx = Math.max(acIdx - 1, 0); highlightAcItem(); } - else if (e.key === 'Enter' || e.key === 'Tab') { + else if (e.key === 'Enter') { e.preventDefault(); if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]); } diff --git a/frontend/js/autocomplete.js b/frontend/js/autocomplete.js index 86492aa..2b2d070 100644 --- a/frontend/js/autocomplete.js +++ b/frontend/js/autocomplete.js @@ -347,4 +347,97 @@ function filterFiles(partial) { }); } -export { suggest, searchWikilinks, detectContext, MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT }; +// ── Word completion (Forge « Tab » completion) ──────────────────────── +// A word character matches the same set used by the editor: letters, digits, +// underscore, dash, dot and slash (so paths like `docs/guide` complete too). +var _WORD_CHAR = /[\w\-.\/]/; + +function isWordChar(ch) { + return !!ch && _WORD_CHAR.test(ch); +} + +/** Return the word fragment immediately before the cursor. */ +function getWordFragment(text, cursorPos) { + var start = cursorPos; + while (start > 0 && isWordChar(text.charAt(start - 1))) start--; + return { start: start, fragment: text.slice(start, cursorPos) }; +} + +/** + * Collect unique words of `text` that start with `fragment` (case-insensitive). + * The occurrence currently being typed at the cursor is ignored. `limit` caps + * the number of candidates (0 = no limit). + */ +function findWordCompletions(text, cursorPos, fragment, limit) { + if (!fragment || fragment.length < 2) return []; + var lower = fragment.toLowerCase(); + var re = /[\w\-.\/]+/g; + var seen = Object.create(null); + var out = []; + var m; + while ((m = re.exec(text)) !== null) { + var word = m[0]; + if (word.length <= fragment.length) continue; + if (word.toLowerCase().indexOf(lower) !== 0) continue; + // Skip the word currently being typed (it starts exactly at the cursor). + if (m.index === cursorPos - fragment.length) continue; + var key = word.toLowerCase(); + if (seen[key]) continue; + seen[key] = true; + out.push(word); + if (limit && out.length >= limit) break; + } + return out; +} + +/** + * Clean up a raw AI prediction before displaying/inserting it. + * + * - strips the echoed input prefix (mid-word or full context), + * - for a mid-word completion, keeps only the first token so a word completion + * can never introduce a stray space, + * - trims any remaining leading whitespace. + */ +function normalizeGhost(raw, inputText, midWord) { + var p = raw == null ? '' : String(raw).trim(); + if (!p) return ''; + if (inputText) { + var inp = String(inputText); + if (midWord && p.toLowerCase().indexOf(inp.toLowerCase()) === 0) { + p = p.slice(inp.length); + } else if (!midWord && p.indexOf(inp) === 0) { + p = p.slice(inp.length); + } + } + if (midWord) p = p.split(/\s+/)[0]; + return p.replace(/^\s+/, ''); +} + +/** + * Decide what the « Tab » key should do. Kept pure so the editor and the tests + * share the exact same priority rules. + * + * dropdown → an autocomplete list is open: accept the highlighted item + * ghost → an AI inline prediction is displayed: accept it + * dedent → Shift+Tab: remove indentation + * word → a single document word matches: insert its suffix + * word-list → several words match: open the suggestion list + * indent → default: indent the current line + */ +function chooseTabAction(state) { + state = state || {}; + if (state.dropdownOpen) return 'dropdown'; + if (state.ghost) return 'ghost'; + if (state.shiftKey) return 'dedent'; + if (state.hasSelection) return 'indent'; + var candidates = state.candidates || []; + if (candidates.length === 1) return 'word'; + if (candidates.length > 1) return 'word-list'; + return 'indent'; +} + +export { + suggest, searchWikilinks, detectContext, + MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT, + isWordChar, getWordFragment, findWordCompletions, normalizeGhost, chooseTabAction, +}; diff --git a/package.json b/package.json index 4bed760..d45f7cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.8.0", + "version": "2.8.1", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/forge-completion.test.mjs b/tests/frontend/forge-completion.test.mjs new file mode 100644 index 0000000..404d3f0 --- /dev/null +++ b/tests/frontend/forge-completion.test.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +/** + * ObsiGate — Forge autocomplete tests (BUG-055). + * + * The Forge editor (`frontend/editor-poc.html`) shares its pure completion + * helpers with CodeMirror through `frontend/js/autocomplete.js`. This suite + * pins the behaviour that fixes the « Tab adds a stray space » bug: + * + * - getWordFragment / findWordCompletions — document word completion + * - normalizeGhost — AI inline prediction cleanup + * - chooseTabAction — single action per Tab press + * + * Usage: node tests/frontend/forge-completion.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +const mod = await import( + pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "autocomplete.js")).href +); +const { + isWordChar, + getWordFragment, + findWordCompletions, + normalizeGhost, + chooseTabAction, +} = mod; + +let testCount = 0; +let failCount = 0; + +function test(name, fn) { + testCount++; + try { + fn(); + console.log(` ✓ ${name}`); + } catch (err) { + failCount++; + console.error(` ✗ ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +// ── isWordChar ───────────────────────────────────────────────────────────── +test("isWordChar accepts letters, digits, _ - . /", () => { + for (const ch of ["a", "Z", "0", "_", "-", ".", "/"]) { + assert.equal(isWordChar(ch), true, `expected ${JSON.stringify(ch)} to be a word char`); + } +}); + +test("isWordChar rejects spaces and punctuation", () => { + for (const ch of [" ", "\n", "!", "@", "#", "(", "\t"]) { + assert.equal(isWordChar(ch), false, `expected ${JSON.stringify(ch)} not to be a word char`); + } + assert.equal(isWordChar(""), false); + assert.equal(isWordChar(undefined), false); +}); + +// ── getWordFragment ──────────────────────────────────────────────────────── +test("getWordFragment returns the fragment before the cursor", () => { + const text = "hello wor"; + assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "wor" }); +}); + +test("getWordFragment stops at non-word characters", () => { + const text = "a (tabl"; + assert.deepEqual(getWordFragment(text, text.length), { start: 3, fragment: "tabl" }); +}); + +test("getWordFragment returns an empty fragment at a word boundary", () => { + const text = "hello "; + assert.deepEqual(getWordFragment(text, text.length), { start: 6, fragment: "" }); +}); + +test("getWordFragment handles a mid-document cursor", () => { + const text = "one two three"; + // cursor right after "tw" + assert.deepEqual(getWordFragment(text, 6), { start: 4, fragment: "tw" }); +}); + +// ── findWordCompletions ──────────────────────────────────────────────────── +test("findWordCompletions returns prefix matches from the document", () => { + const text = "table tableau tab\n"; + const cursor = text.length; + assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table", "tableau"]); +}); + +test("findWordCompletions ignores the occurrence being typed", () => { + const text = "table and tab"; + const cursor = text.length; // typing the trailing "tab" + assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["table"]); +}); + +test("findWordCompletions is case-insensitive and unique", () => { + const text = "Table TABLE table\n"; + const cursor = text.length; + assert.deepEqual(findWordCompletions(text, cursor, "tab", 8), ["Table"]); +}); + +test("findWordCompletions completes file-like tokens", () => { + const text = "docs/guide.md docs/guide\n"; + const cursor = text.length; + assert.deepEqual(findWordCompletions(text, cursor, "docs/", 8), ["docs/guide.md", "docs/guide"]); +}); + +test("findWordCompletions honours the limit", () => { + const text = "abc abd abe abf abg\n"; + const cursor = text.length; + assert.equal(findWordCompletions(text, cursor, "ab", 3).length, 3); +}); + +test("findWordCompletions needs at least two characters", () => { + const text = "apple apricot\n"; + assert.deepEqual(findWordCompletions(text, text.length, "a", 8), []); + assert.deepEqual(findWordCompletions(text, text.length, "", 8), []); +}); + +// ── normalizeGhost ───────────────────────────────────────────────────────── +test("normalizeGhost strips an echoed prefix (mid-word)", () => { + assert.equal(normalizeGhost("familial", "famil", true), "ial"); +}); + +test("normalizeGhost keeps only the first token for a word completion", () => { + // A word completion must never introduce a space. + assert.equal(normalizeGhost("familial and more", "famil", true), "ial"); +}); + +test("normalizeGhost tolerates a leading space from the model", () => { + assert.equal(normalizeGhost(" monde", "bonjour", false), "monde"); +}); + +test("normalizeGhost strips a repeated full context", () => { + assert.equal(normalizeGhost("Bonjour le monde", "Bonjour", false), " le monde".trim()); +}); + +test("normalizeGhost returns an empty string for empty input", () => { + assert.equal(normalizeGhost("", "abc", true), ""); + assert.equal(normalizeGhost(null, "abc", true), ""); + assert.equal(normalizeGhost(" ", "abc", false), ""); +}); + +test("normalizeGhost keeps a phrase continuation intact", () => { + assert.equal(normalizeGhost("la suite de la phrase.", "Voici", false), "la suite de la phrase."); +}); + +// ── chooseTabAction ──────────────────────────────────────────────────────── +test("chooseTabAction prioritises the open list", () => { + assert.equal(chooseTabAction({ dropdownOpen: true, ghost: true }), "dropdown"); +}); + +test("chooseTabAction accepts a ghost prediction before word completion", () => { + assert.equal(chooseTabAction({ ghost: true, candidates: ["table"] }), "ghost"); +}); + +test("chooseTabAction inserts a single word match", () => { + assert.equal(chooseTabAction({ candidates: ["table"] }), "word"); +}); + +test("chooseTabAction opens the list for several matches", () => { + assert.equal(chooseTabAction({ candidates: ["table", "tableau"] }), "word-list"); +}); + +test("chooseTabAction indents when nothing matches", () => { + assert.equal(chooseTabAction({ candidates: [] }), "indent"); + assert.equal(chooseTabAction({}), "indent"); +}); + +test("chooseTabAction dedents on Shift+Tab", () => { + assert.equal(chooseTabAction({ shiftKey: true }), "dedent"); + assert.equal(chooseTabAction({ shiftKey: true, candidates: ["table"] }), "dedent"); +}); + +test("chooseTabAction indents when a selection exists", () => { + assert.equal(chooseTabAction({ hasSelection: true, candidates: ["table"] }), "indent"); +}); + +// ── Static wiring of frontend/editor-poc.html ────────────────────────────── +import { readFileSync } from "node:fs"; +const FORGE_HTML = readFileSync( + path.join(REPO_ROOT, "frontend", "editor-poc.html"), + "utf-8", +); + +test("Forge uses the shared completion helpers", () => { + for (const call of ["ac.getWordFragment(", "ac.findWordCompletions(", "ac.chooseTabAction("]) { + assert.ok(FORGE_HTML.includes(call), `editor-poc.html must call ${call}`); + } +}); + +test("Forge has a single indentation call site (no double Tab handling)", () => { + const indentCalls = FORGE_HTML.match(/indentLine\(\);/g) || []; + assert.equal(indentCalls.length, 1, "indentLine() must only be called from the unified Tab handler"); +}); + +test("Forge ghost text no longer mirrors the whole document", () => { + assert.ok(!FORGE_HTML.includes("ghostOverlay.innerHTML"), "the mirror overlay must be gone"); +}); + +// ── Summary ──────────────────────────────────────────────────────────────── +console.log(`\n${testCount} passed, ${failCount} failed`); +process.exit(failCount > 0 ? 1 : 0);