Compare commits

...
4 Commits
Author SHA1 Message Date
bruno 94ea5909f4 fix(forge): le parent quitte aussi le plein ecran avant l'assistant IA (BUG-056)
CI / lint (push) Successful in 1m34s
CI / security (push) Successful in 1m1s
CI / test (push) Successful in 2m11s
CI / build (push) Successful in 57s
CI / e2e (push) Successful in 10m50s
2026-09-17 09:19:06 -04:00
bruno 3758db2861 fix(forge): quitter le plein ecran avant d'ouvrir l'assistant IA (BUG-056)
CI / lint (push) Successful in 1m33s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m7s
CI / build (push) Successful in 57s
CI / e2e (push) Successful in 10m56s
2026-09-17 09:07:00 -04:00
bruno 8b09093aca fix(forge): ne plus ecraser une completion acceptee au Tab (BUG-055)
CI / lint (push) Successful in 1m34s
CI / security (push) Successful in 1m1s
CI / test (push) Successful in 2m44s
CI / build (push) Successful in 57s
CI / e2e (push) Successful in 11m14s
2026-09-17 09:00:35 -04:00
bruno 61347e0f0b fix(forge): autocompletion Tab naturelle et fiable (BUG-055)
CI / lint (push) Successful in 1m34s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m30s
CI / build (push) Successful in 57s
CI / e2e (push) Successful in 10m54s
2026-09-17 08:48:03 -04:00
18 changed files with 686 additions and 138 deletions
+3 -1
View File
@@ -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: |
+63 -1
View File
@@ -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.4**.
---
@@ -14,6 +14,68 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.8.4] — 2026-09-17
---
## [2.8.3] — 2026-09-17
### Corrigé
- **BUG-056 - Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan** :
le panneau de l'assistant est monté dans le document parent, alors que le plein écran
Forge porte sur l'iframe — l'API Fullscreen n'affichant que l'élément plein écran et ses
descendants, le panneau restait invisible. Le plein écran est désormais quitté avant
d'ouvrir le panneau : côté **parent** (`sync.js`, sur `forge-open-ai` — le plein écran
peut appartenir au document parent et non à l'iframe) **et** côté iframe
(`editor-poc.html`, `openAssistant`), la demande d'ouverture étant émise une fois la
sortie effective. Fichiers : `frontend/editor-poc.html`, `frontend/js/sync.js`.
Tests : `tests/frontend/forge-completion.test.mjs` (+1),
`tests/frontend/editor-inline.test.mjs` (+1).
---
## [2.8.2] — 2026-09-17
### Corrigé
- **BUG-055 (complément) - Éditeur Forge : une complétion acceptée au `Tab` était
supprimée 1–2 s plus tard** : l'auto-sauvegarde (2 s) déclenche un événement SSE
`index_updated` sur le fichier en cours, et le parent rechargeait alors le tampon de
Forge **depuis le disque** — écrasant une complétion (ou toute frappe) faite après la
sauvegarde. Le rechargement SSE est désormais ignoré tant que le tampon local est
modifié (`isDirty`) ; seul un écrit externe (assistant IA) force le rechargement.
L'auto-sauvegarde ne repasse plus l'état « enregistré » si des modifications sont
arrivées pendant la requête (Forge **et** éditeur CodeMirror), et l'acceptation du
ghost annule la requête de prédiction en attente. Fichiers : `frontend/editor-poc.html`,
`frontend/js/utils.js`. Tests : `tests/frontend/forge-completion.test.mjs` (+3),
`tests/frontend/editor-inline.test.mjs` (+1).
---
## [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é
+3 -3
View File
@@ -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.4-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.4).
---
*Projet : ObsiGate | Version : 2.8.0 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.8.4 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -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.4-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.4).
---
*Project: ObsiGate | Version: 2.8.0 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.8.4 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.8.0
2.8.4
+6 -3
View File
@@ -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,
)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.8.0"
version = "2.8.4"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.8.0"
version = "2.8.4"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -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.4",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+5
View File
@@ -164,6 +164,8 @@ 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 |
| *BUG-056* | [🟡 IMPORTANT] Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan et reste invisible | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Forge : passer en plein écran puis cliquer le bouton « Assistant IA » (ou `Ctrl+J`) — le panneau s'ouvre dans le document parent, masqué par l'iframe plein écran | Sortie du plein écran **avant** d'ouvrir le panneau, des deux côtés : côté iframe (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et** côté parent (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut être détenu par le document parent et non par l'iframe (dans ce cas `document.fullscreenElement` est nul dans l'iframe et sa sortie échoue). Tests : `forge-completion.test.mjs` (+1), `editor-inline.test.mjs` (+1) | Le panneau assistant est monté dans `document.body` du parent : l'API Fullscreen ne rend que l'élément plein écran et ses descendants, donc il ne peut pas s'afficher au-dessus de l'iframe Forge en plein écran. La sortie côté iframe seule ne suffisait pas quand le parent détient le plein écran |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -220,6 +222,9 @@ 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) |
| 2026-09-17 | BUG-055 (complément) | Correction | `frontend/editor-poc.html`, `frontend/js/utils.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-055 (complément)** : une complétion acceptée au `Tab` disparaissait 1–2 s plus tard. Cause : l'auto-sauvegarde (2 s) déclenche un `index_updated` SSE sur le fichier affiché, et `reloadExternalWrite` rechargeait le tampon Forge **depuis le disque**, écrasant toute frappe postérieure à la sauvegarde. Le rechargement SSE est désormais ignoré si le tampon est modifié (`parent-reload` sans `force` + `isDirty` ; garde équivalente sur le point d'auto-sauvegarde CodeMirror) ; seul `obsigate:file-written` (assistant IA) passe `force=true`. L'auto-sauvegarde ne remet plus l'état « enregistré » si des modifications sont arrivées pendant la requête (Forge + CodeMirror), et `acceptGhost()` annule la requête de prédiction en attente. Vérifié : `forge-completion.test.mjs` 31/31 (+3), `editor-inline.test.mjs` 41/41 (+1), 14 suites frontend vertes, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-056 | Correction | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-056** : en plein écran Forge, l'Assistant IA s'ouvrait en arrière-plan. La sortie du plein écran est désormais faite **côté iframe** (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et côté parent** (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut appartenir au document parent (l'iframe voit alors `fullscreenElement` nul et sa sortie échoue — c'était le cas non couvert par le premier correctif). Vérifié : `forge-completion.test.mjs` 32/32 (+1), `editor-inline.test.mjs` 42/42 (+1), 14 suites frontend vertes, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
---
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.8.0 | **Dernière mise à jour :** 2026-09-17
> **Version :** 2.8.4 | **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)**
+204 -107
View File
@@ -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; }
</style>
</head>
<body>
@@ -390,7 +386,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<tr><td>/</td><td>Menu de commandes (en debut de ligne)</td></tr>
<tr><td>Alt+I</td><td>Insertion rapide (Quick Insert)</td></tr>
<tr><td>F1</td><td>Ce panneau d'aide</td></tr>
<tr><td>Tab</td><td>Indenter la ligne / element de liste</td></tr>
<tr><td>Tab</td><td>Valider la suggestion / completer le mot / indenter</td></tr>
<tr><td>Shift+Tab</td><td>Desindenter</td></tr>
</table>
<h3>Formatage</h3>
@@ -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);
@@ -574,7 +575,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
body: JSON.stringify({ content: content })
}).then(function(r) {
if (!r.ok) throw new Error(r.status);
isDirty = false; originalContent = content;
originalContent = content;
// BUG-055 — edits typed while the save was in flight are newer than the
// disk: stay dirty (and save again) so a later SSE reload can't drop them.
if (val() !== content) { scheduleAutoSave(); return; }
isDirty = false;
saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved';
}).catch(function(e) {
saveDot.className = 'save-dot err'; saveLabel.textContent = 'Erreur';
@@ -853,7 +858,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 +889,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')); }
@@ -1155,9 +1200,23 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
// iframe, so the AI button simply asks the parent to open it — no duplicated
// panel, and the configured provider/model is shared automatically.
function openAssistant() {
try {
window.parent.postMessage({ type: 'forge-open-ai' }, '*');
} catch (e) { /* not embedded */ }
var notify = function() {
try {
window.parent.postMessage({ type: 'forge-open-ai' }, '*');
} catch (e) { /* not embedded */ }
};
// The shared assistant lives in the parent document, which cannot render
// above a fullscreen Forge iframe: leave fullscreen first so the panel is
// actually visible when it opens.
if (document.fullscreenElement && document.exitFullscreen) {
try {
var p = document.exitFullscreen();
if (p && p.then) p.then(notify, notify);
else notify();
} catch (e) { notify(); }
} else {
notify();
}
}
// ---- Fullscreen (#101) ----
@@ -1275,11 +1334,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(); }
});
@@ -1425,6 +1479,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
// and drop the stale local buffer that would otherwise be autosaved back
// over the assistant's change.
if (e.data.type === 'parent-reload') {
// #93/BUG-055 — the SSE `index_updated` broadcast is often caused by this
// editor's own autosave. Reloading from disk then would clobber edits made
// after the save (e.g. a Tab completion accepted in the meantime). Only an
// external write (AI assistant) forces the reload past unsaved changes.
if (!e.data.force && isDirty) return;
clearTimeout(saveTimer);
isDirty = false;
loadFile();
@@ -1502,10 +1561,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 +1580,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 +1736,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 ? '<div class="ac-item-context">' + context + '</div>' : '';
items.forEach(function(item, i) {
@@ -1698,6 +1794,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 +1840,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
hideAutocomplete();
clearGhost();
ta.focus();
markDirty();
autoHeight();
@@ -1768,25 +1882,28 @@ 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) + '<span class="ghost-prediction">' + escHtml(prediction) + '</span>';
// 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() {
if (!_ghostText) return;
if (_ghostTimer) { clearTimeout(_ghostTimer); _ghostTimer = null; }
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 +1914,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 +1937,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 +1958,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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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 +1974,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]);
}
+94 -1
View File
@@ -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,
};
+20 -5
View File
@@ -649,11 +649,26 @@ export function init() {
// #101 — Forge has no rich AI panel of its own: its AI button asks the
// parent to open the shared AI Assistant (same provider/model, skills,
// context and history).
import('./bookslm.js').then(function(m) {
if (m && m.default) m.default.openForCurrentContext();
}).catch(function(err) {
console.warn('[Forge] Unable to open AI assistant', err);
});
var openForgeAssistant = function() {
import('./bookslm.js').then(function(m) {
if (m && m.default) m.default.openForCurrentContext();
}).catch(function(err) {
console.warn('[Forge] Unable to open AI assistant', err);
});
};
// BUG-056 — the assistant panel is mounted in this document. The native
// fullscreen may be owned by the parent (Forge iframe) rather than by the
// iframe itself, so the parent must also leave fullscreen before the
// panel can be seen.
if (document.fullscreenElement && document.exitFullscreen) {
try {
document.exitFullscreen().catch(function() {}).then(openForgeAssistant, openForgeAssistant);
} catch (err) {
openForgeAssistant();
}
} else {
openForgeAssistant();
}
}
});
}
+26 -6
View File
@@ -653,17 +653,22 @@ function _invalidateActiveTabCache(vault, path) {
* pre-write content, and — worse — an open editor holds the old text in memory
* and its 2s autosave would overwrite the assistant's change with it.
*/
async function reloadExternalWrite(vault, path) {
async function reloadExternalWrite(vault, path, force = false) {
if (!vault || !path) return;
const isEdited = state.editorVault === vault && state.editorPath === path;
if (isEdited) {
// Forge hosts its own buffer inside an iframe: ask it to reload from disk.
var forgeFrame = document.getElementById("forge-iframe");
if (forgeFrame && forgeFrame.contentWindow) {
forgeFrame.contentWindow.postMessage({ type: 'parent-reload' }, '*');
forgeFrame.contentWindow.postMessage({ type: 'parent-reload', force: !!force }, '*');
return;
}
if (!state.editorView) return;
// BUG-055 — a non-forced reload (SSE `index_updated`) is often caused by the
// editor's own autosave: reloading then would clobber edits made after the
// save. Skip while the save dot reports unsaved local changes.
var dirtyDot = document.getElementById("editor-save-dot");
if (!force && dirtyDot && dirtyDot.classList.contains("dirty")) return;
try {
const rawUrl = `/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`;
const rawData = await api(rawUrl);
@@ -757,9 +762,23 @@ async function saveFile(silent = false) {
throw new Error(error.detail || "Erreur de sauvegarde");
}
// Update save dot to saved state
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
if (saveLabel) saveLabel.textContent = 'Saved';
// BUG-055 — only mark clean when nothing changed while saving; otherwise
// keep the unsaved state and schedule another autosave (an SSE reload of
// the file must not drop edits typed during the request).
const currentContent = state.editorView
? state.editorView.state.doc.toString()
: state.fallbackEditorEl
? state.fallbackEditorEl.value
: content;
if (silent && currentContent !== content) {
if (saveDot) { saveDot.className = 'editor-save-dot dirty'; }
if (saveLabel) saveLabel.textContent = 'Unsaved';
clearTimeout(window._obsigateAutoSaveTimer);
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
} else {
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
if (saveLabel) saveLabel.textContent = 'Saved';
}
if (silent) {
// Auto-save: brief green flash on save button
@@ -921,7 +940,8 @@ function initEditor() {
// visible right away and never overwritten by the editor's autosave.
window.addEventListener("obsigate:file-written", (e) => {
const detail = (e && e.detail) || {};
reloadExternalWrite(detail.vault, detail.path);
// force: the assistant's write must win over the stale local buffer.
reloadExternalWrite(detail.vault, detail.path, true);
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.8.0",
"version": "2.8.4",
"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": {
+19 -2
View File
@@ -275,14 +275,23 @@ test("utils.js detachInlineEditor tears the session down without re-rendering",
});
test("utils.js reloads the displayed document after an AI write", () => {
const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path\) \{([\s\S]*?)\n\}/);
const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path, force = false\) \{([\s\S]*?)\n\}/);
assert.ok(fn, "reloadExternalWrite not found");
assert.match(fn[1], /postMessage\(\{ type: 'parent-reload' \}, '\*'\)/);
assert.match(fn[1], /postMessage\(\{ type: 'parent-reload', force: !!force \}, '\*'\)/);
assert.match(fn[1], /suppressAutoSaveOnce = true;/);
assert.match(utilsSrc, /window\.addEventListener\("obsigate:file-written"/);
assert.match(utilsSrc, /if \(suppressAutoSaveOnce\) \{/, "autosave skipped on programmatic reload");
});
test("utils.js does not clobber unsaved edits on a non-forced reload (BUG-055)", () => {
const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path, force = false\) \{([\s\S]*?)\n\}/);
assert.ok(fn, "reloadExternalWrite not found");
// Guard before the CodeMirror dispatch
assert.match(fn[1], /if \(!force && dirtyDot && dirtyDot\.classList\.contains\("dirty"\)\) return;/);
// The AI write forces the reload past unsaved local changes
assert.match(utilsSrc, /reloadExternalWrite\(detail\.vault, detail\.path, true\);/);
});
test("sync.js forge-close goes through the shared close path", () => {
assert.ok(syncSrc.includes("import { closeEditor, reloadExternalWrite } from './utils.js';"), "utils import missing");
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/);
@@ -290,6 +299,14 @@ test("sync.js forge-close goes through the shared close path", () => {
assert.match(handler[1], /closeEditor\(\);/);
});
test("sync.js leaves fullscreen before opening the Forge assistant (BUG-056)", () => {
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-open-ai'\) \{([\s\S]*?)\n \}/);
assert.ok(handler, "forge-open-ai handler not found");
assert.match(handler[1], /document\.fullscreenElement/);
assert.match(handler[1], /document\.exitFullscreen\(\)/);
assert.match(handler[1], /openForCurrentContext\(\)/);
});
test("sync.js keeps an open edition session alive on external file changes", () => {
const sse = syncSrc.match(/const changed = \(data\.changes \|\| \[\]\)([\s\S]*?)\n \}/);
assert.ok(sse, "SSE index_updated refresh block not found");
+234
View File
@@ -0,0 +1,234 @@
#!/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");
});
test("Forge ignores its own autosave reload while the buffer is dirty (BUG-055)", () => {
assert.ok(
FORGE_HTML.includes("if (!e.data.force && isDirty) return;"),
"parent-reload must not clobber unsaved local edits",
);
});
test("Forge cancels a pending ghost request when the prediction is accepted", () => {
const fn = FORGE_HTML.match(/function acceptGhost\(\) \{([\s\S]*?)\n \}/);
assert.ok(fn, "acceptGhost not found");
assert.match(fn[1], /clearTimeout\(_ghostTimer\)/);
});
test("Forge autosave keeps the buffer dirty if edits arrive during the request", () => {
const fn = FORGE_HTML.match(/function autoSave\(\) \{([\s\S]*?)\n \}/);
assert.ok(fn, "autoSave not found");
assert.match(fn[1], /if \(val\(\) !== content\) \{ scheduleAutoSave\(\); return; \}/);
});
test("Forge leaves fullscreen before opening the shared assistant", () => {
const fn = FORGE_HTML.match(/function openAssistant\(\) \{([\s\S]*?)\n \}/);
assert.ok(fn, "openAssistant not found");
assert.match(fn[1], /document\.fullscreenElement/);
assert.match(fn[1], /document\.exitFullscreen\(\)/);
assert.match(fn[1], /postMessage\(\{ type: 'forge-open-ai' \}, '\*'\)/);
});
// ── Summary ────────────────────────────────────────────────────────────────
console.log(`\n${testCount} passed, ${failCount} failed`);
process.exit(failCount > 0 ? 1 : 0);