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

This commit is contained in:
2026-09-17 09:00:35 -04:00
parent 61347e0f0b
commit 8b09093aca
14 changed files with 99 additions and 22 deletions
+19 -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.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.8.2**.
---
@@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
+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.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.8.2-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.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.8.2).
---
*Projet : ObsiGate | Version : 2.8.1 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.8.2 | 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.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.8.2-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.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.8.2).
---
*Project: ObsiGate | Version: 2.8.1 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.8.2 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.8.1
2.8.2
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.8.1"
version = "2.8.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.8.1"
version = "2.8.2"
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.1",
"version": "2.8.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1
View File
@@ -222,6 +222,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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) |
---
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.8.1 | **Dernière mise à jour :** 2026-09-17
> **Version :** 2.8.2 | **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)**
+11 -1
View File
@@ -575,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';
@@ -1461,6 +1465,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();
@@ -1875,6 +1884,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
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);
+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.1",
"version": "2.8.2",
"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": {
+11 -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 \}/);
+19
View File
@@ -202,6 +202,25 @@ 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; \}/);
});
// ── Summary ────────────────────────────────────────────────────────────────
console.log(`\n${testCount} passed, ${failCount} failed`);
process.exit(failCount > 0 ? 1 : 0);