diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a8c2a..95bfe54 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.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é diff --git a/README.fr.md b/README.fr.md index b049701..7e633a6 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.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* diff --git a/README.md b/README.md index 93a7d4c..4d64a57 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.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* diff --git a/VERSION b/VERSION index dbe5900..1817afe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.1 +2.8.2 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index ff226dc..f661638 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.8.1" +version = "2.8.2" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 364b319..f997757 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 61bac9f..cfc2af2 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.1", + "version": "2.8.2", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 6f1cd68..260918a 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -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) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 00a3d49..9cffdd8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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)** diff --git a/frontend/editor-poc.html b/frontend/editor-poc.html index bec209a..5293375 100644 --- a/frontend/editor-poc.html +++ b/frontend/editor-poc.html @@ -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); diff --git a/frontend/js/utils.js b/frontend/js/utils.js index a75a465..a564fe1 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -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); }); } diff --git a/package.json b/package.json index d45f7cb..9ad60a8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/tests/frontend/editor-inline.test.mjs b/tests/frontend/editor-inline.test.mjs index db35b28..59141d7 100644 --- a/tests/frontend/editor-inline.test.mjs +++ b/tests/frontend/editor-inline.test.mjs @@ -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 \}/); diff --git a/tests/frontend/forge-completion.test.mjs b/tests/frontend/forge-completion.test.mjs index 404d3f0..a2f0648 100644 --- a/tests/frontend/forge-completion.test.mjs +++ b/tests/frontend/forge-completion.test.mjs @@ -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);