diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0fc6b..87d75ac 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.3.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.3.3**. --- @@ -14,6 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.3.3] — 2026-09-16 + +### Corrigé + +- **#93 (complément) — garde-fous de session d'édition inline** : une session d'édition + (« Editer » ou Forge) qui possède la zone de contenu était détruite par deux chemins + qui vident cette zone sans la libérer d'abord — l'**activation d'un onglet** + (`TabManager.activate`, `PaneTabManager.activate`) et l'événement SSE **`index_updated`** + sur le fichier affiché (un fichier modifié hors de l'app relançait `openFile()` et écrasait + le DOM de l'éditeur). L'activation d'onglet appelle désormais `detachInlineEditor()` avant + le placeholder de chargement, et l'écriture externe passe par `reloadExternalWrite()` (le + **tampon de l'éditeur** est rechargé depuis le disque au lieu d'un re-rendu de la vue + lecture). Nouveau helper `queryEditor()` (`editor-inline.js`) : l'en-tête, le pied et la + marque voyagent avec le conteneur en mode inline, un `modal.querySelector()` les manquait ; + `closeEditor()` remet le conteneur dans la modale **avant** de restaurer en-tête/pied/marque. + Fiche : [docs/features/editeur-inline.md](./docs/features/editeur-inline.md). + Tests : `tests/frontend/editor-inline.test.mjs` (25). +- **Documentation — hooks git obligatoires à l'installation** : `docs/CONTRIBUTING.md` + documente l'appel à `scripts/install-hooks.sh` (versionnage automatique) dans les étapes + de mise en place d'un clone. + +--- + ## [2.3.2] — 2026-09-16 --- diff --git a/README.fr.md b/README.fr.md index d990b95..804358f 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.3.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.3.3-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.3.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.3.3). --- -*Projet : ObsiGate | Version : 2.3.2 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.3.3 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index d3ead18..5b788ff 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.3.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.3.3-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.3.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.3.3). --- -*Project: ObsiGate | Version: 2.3.2 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.3.3 | Last updated: May 2026* diff --git a/VERSION b/VERSION index f90b1af..0bee604 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.2 +2.3.3 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index d86c8c8..4db77d3 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.3.2" +version = "2.3.3" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 71f8b3c..aa67ab7 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.3.2" +version = "2.3.3" 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 5230d96..ff6cf15 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.3.2", + "version": "2.3.3", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7fcd062..c82b283 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -30,8 +30,17 @@ source .venv/bin/activate # Linux/macOS # .venv\Scripts\activate # Windows pip install -r backend/requirements.txt + +# Activer les hooks git versionnés (.githooks) : incrément automatique de la +# version livrée (VERSION) à chaque commit + publication du tag vX.Y.Z au push. +scripts/install-hooks.sh ``` +> **Hooks obligatoires** : `scripts/install-hooks.sh` pose `core.hooksPath=.githooks` et +> `push.followTags=true`. Sans eux, la version (`VERSION`) ne suit plus les livraisons et le +> CI reste sur un numéro obsolète. Détail : [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) §3. +> Contournement ponctuel d'un commit : `SKIP_VERSION_BUMP=1 git commit …`. + ### 2. Configurer les vaults de test Créez un dossier `test_vault/` (ignoré par `.gitignore`) avec quelques fichiers `.md` : diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index c1fd8c7..b77c137 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -200,7 +200,8 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-14 | BUG-043, #82 | Correction | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-provider-picker.md` | La liste des fournisseurs de la barre latérale de l'assistant suit désormais la configuration du projet : nouveau `refreshAIPickers()` (`ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (host conservé dans la barre de l'assistant, montage par `replaceChildren`), appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; slot préservé même sans fournisseur configuré (le premier fournisseur ajouté s'y monte) ; sélection persistée d'un fournisseur non configuré purgée de `obsigate_ai_picker` (retour au défaut, modèle effacé). Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM vertes, validate-imports 36 modules, pytest 963 passed / 6 skipped, ruff OK, et vérification en navigateur (Playwright) sur l'instance de test : ajout de `nvidia` → fournisseur visible sans rechargement, retrait → disparition + sélection réinitialisée (`{"provider":null,"model":null}`), un seul montage du picker dans son host. CI Gitea verte (lint, test, security, build, e2e). | | 2026-09-15 | BUG-044 | Correction | `backend/provider_capabilities.py` (nouveau), `backend/model_capabilities.py`, `backend/main.py`, `backend/ai_routes.py`, `tests/test_provider_capabilities.py` (nouveau), `tests/test_model_capabilities.py`, `tests/test_ai_models.py`, `docs/features/ai-provider-picker.md`, `CHANGELOG.md` | BUG-044 : les capacités des modèles IA sont désormais lues chez le fournisseur quand il les publie (Mistral `capabilities`, OpenRouter `architecture`, détection par forme du payload), mises en cache par `GET /api/config/ai-models` (aucune requête supplémentaire) et prioritaires sur la table statique qui ne comble plus que les drapeaux non déclarés (repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings` (mistral-large / codestral n'étaient plus des « embedders »). Vérifié : diagnostic live avant/après sur l'API Mistral (28 modèles vision déclarés, **0** détectés avant → **28** après, 0 écart dans les deux sens), pytest 1007 passed / 6 skipped, ruff 0 (backend), mypy 0 (68 fichiers), tests frontend (unit 9/9, IA 66/66, validate-imports 37 modules), et vérification sur l'instance de test reconstruite (`obsigate-test`, http://localhost:2020) via les deux endpoints du panneau : `mistral-small/medium-latest`, `ministral-8b-latest`, `magistral-small-latest` → `chat,vision` ; `mistral-ocr-latest` → `vision` seul ; `mistral-large-latest`/`codestral-latest` → `chat` ; `mistral-embed` → `embeddings` seul ; `deepseek-chat` (fournisseur muet) inchangé. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-045, BUG-046 | Correction | `frontend/style.css`, `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py`, `frontend/sw.js`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md` | BUG-045 : fin de la double barre de défilement de l'éditeur « Editer » — `#editor-body` en flex column `overflow:hidden`, `.cm-editor` `flex:1 1 auto`, seul le scroller CodeMirror défile ; suppression de l'override global `.cm-scroller` (min-height/overflow-y !important). BUG-046 : « Échec de l'action : [object Object] » à l'application d'un ajout de texte au document courant — (1) `_responseError()` aplatit le `detail` 422 (tableau d'objets FastAPI) en message lisible, (2) la continuation de confirmation hérite du payload d'origine (le second « Appliquer » ne POSTe plus sans `message`), (3) le prompt documents/dossier nomme le vault et enjoint les outils d'écriture d'utiliser ce nom exact (le modèle inventait `"vault":"test"` → échec silencieux). `SW_VERSION` v20. Vérifié : pytest 1038 passed / 6 skipped, ruff 0 (backend), tests frontend IA 84/84, editor-inline 25/25, validate-imports 38 modules ; reproduction et validation Playwright sur l'instance de test (un seul conteneur scrollable après correctif). | 🟢 corrigé (en attente vérif utilisateur) | -| 2026-09-16 | BUG-047 | Correction + build | `VERSION` (nouveau), `scripts/bump_version.py` (nouveau), `.githooks/prepare-commit-msg` + `.githooks/post-commit` (nouveaux), `scripts/install-hooks.sh` (nouveau), `scripts/bump_version.sh`, `backend/version.py`, `Dockerfile`, `docker-compose.yml`, `build.sh`, `.gitea/workflows/ci.yml`, `desktop/build.rs`, `package.json`, `README.md`, `README.fr.md`, `docs/ROADMAP.md`, `docs/DELIVERY_WORKFLOW.md`, `docs/DEVELOPMENT_AND_RELEASES.md`, `tests/test_version.py` (nouveau) | **Version alignée de bout en bout.** Cause : la version affichée venait du dernier **tag git** et aucun tag n'était créé aux livraisons → 66 commits livrés mais UI/API figées sur `2.2.1` ; quatre autres numéros codés en dur dérivaient (`package.json` 1.0.0, desktop 2.0.0, `Dockerfile` 2.2.1, README 1.7.0). Nouveau modèle : **`VERSION` (racine) = source unique de vérité** (`MAJEUR.MINEUR.CORRECTIF`), incrémentée **automatiquement au commit** par le hook versionné `prepare-commit-msg` (SemVer depuis le message : `!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon CORRECTIF ; aucun bump pour merge/revert/`chore(release)`/amend) qui resynchronise `package.json`, desktop Tauri, ROADMAP, READMEs et publie la section `[Unreleased]` du CHANGELOG en `[X.Y.Z] — date` ; `post-commit` rattache ces fichiers au commit qui vient d'être créé (amend immédiat, le commit n'étant pas encore poussé) puis crée le tag `vX.Y.Z`, publié au push (`push.followTags`). `bump_version.py` (avec `--dry-run`, `--set`, `--major|--minor|--patch`, `--print-version`) reste utilisable à la main ; `scripts/install-hooks.sh` installe les hooks. Côté build : Docker `COPY VERSION` (plus d'`ARG VERSION` ni d'env compose codés en dur), `build.sh` et CI lisent `VERSION`, `desktop/build.rs` aussi. Vérifié : `tests/test_version.py` (46 tests, dont le garde-fou `TestRepoVersionAlignment` : VERSION ↔ package.json ↔ desktop ↔ CHANGELOG ↔ ROADMAP ↔ READMEs ↔ pipeline sans numéro codé en dur), pytest complet, ruff/mypy 0, `/api/health` → `2.3.0` sur l'instance de test reconstruite. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-16 | #93 (complément) | Correction | `frontend/js/editor-inline.js`, `frontend/js/utils.js`, `frontend/js/viewer.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/sync.js`, `tests/frontend/editor-inline.test.mjs`, `docs/features/editeur-inline.md`, `docs/CONTRIBUTING.md`, `CHANGELOG.md` | Garde-fous de **session d'édition inline** (#93) : l'activation d'un onglet (`TabManager.activate`, `PaneTabManager.activate`) appelle `detachInlineEditor()` avant de vider la zone de contenu, et l'événement SSE `index_updated` sur le fichier affiché recharge le **tampon de l'éditeur** (`reloadExternalWrite()`) au lieu de re-rendre la vue lecture — ces deux chemins détruisaient la session CodeMirror/Forge en cours. Nouveau helper `queryEditor()` (l'en-tête/pied/marque voyagent avec le conteneur en mode inline, `modal.querySelector()` les manquait) ; `closeEditor()` remet le conteneur dans la modale avant de restaurer en-tête/pied/marque. `docs/CONTRIBUTING.md` documente `scripts/install-hooks.sh` (hooks de versionnage) dans la mise en place d'un clone. Vérifié : validate-imports 38 modules, unit 9/9, suites JSDOM (editor-inline 25/25, pane-manager, mobile-editor 35/35, toolbar-order, ai 84/84, sw 8/8, collab 10/10, semantic-search 4/4, desktop 23, plugins 21, excalidraw-viewer) toutes vertes, pytest 1082 passed / 6 skipped. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-16 | BUG-047 | Correction + build | `VERSION` (nouveau), `scripts/bump_version.py` (nouveau), `.githooks/prepare-commit-msg` + `.githooks/post-commit` (nouveaux), `scripts/install-hooks.sh` (nouveau), `scripts/bump_version.sh`, `backend/version.py`, `Dockerfile`, `docker-compose.yml`, `build.sh`, `.gitea/workflows/ci.yml`, `desktop/build.rs`, `package.json`, `README.md`, `README.fr.md`, `docs/ROADMAP.md`, `docs/DELIVERY_WORKFLOW.md`, `docs/DEVELOPMENT_AND_RELEASES.md`, `tests/test_version.py` (nouveau) | **Version alignée de bout en bout.** Cause : la version affichée venait du dernier **tag git** et aucun tag n'était créé aux livraisons → 66 commits livrés mais UI/API figées sur `2.2.1` ; quatre autres numéros codés en dur dérivaient (`package.json` 1.0.0, desktop 2.0.0, `Dockerfile` 2.2.1, README 1.7.0). Nouveau modèle : **`VERSION` (racine) = source unique de vérité** (`MAJEUR.MINEUR.CORRECTIF`), incrémentée **automatiquement au commit** par le hook versionné `prepare-commit-msg` (SemVer depuis le message : `!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon CORRECTIF ; aucun bump pour merge/revert/`chore(release)`/amend) qui resynchronise `package.json`, desktop Tauri, ROADMAP, READMEs et publie la section `[Unreleased]` du CHANGELOG en `[X.Y.Z] — date` ; `post-commit` rattache ces fichiers au commit qui vient d'être créé (amend immédiat, le commit n'étant pas encore poussé) puis crée le tag `vX.Y.Z`, publié au push (`push.followTags`). `bump_version.py` (avec `--dry-run`, `--set`, `--major\|--minor\|--patch`, `--print-version`) reste utilisable à la main ; `scripts/install-hooks.sh` installe les hooks. Côté build : Docker `COPY VERSION` (plus d'`ARG VERSION` ni d'env compose codés en dur), `build.sh` et CI lisent `VERSION`, `desktop/build.rs` aussi. Vérifié : `tests/test_version.py` (46 tests, dont le garde-fou `TestRepoVersionAlignment` : VERSION ↔ package.json ↔ desktop ↔ CHANGELOG ↔ ROADMAP ↔ READMEs ↔ pipeline sans numéro codé en dur), pytest complet, ruff/mypy 0, `/api/health` → `2.3.0` sur l'instance de test reconstruite. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 62f9480..54b6818 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.3.2 | **Dernière mise à jour :** 2026-09-16 +> **Version :** 2.3.3 | **Dernière mise à jour :** 2026-09-16 > **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/docs/features/editeur-inline.md b/docs/features/editeur-inline.md index 9ac3815..b4a08fd 100644 --- a/docs/features/editeur-inline.md +++ b/docs/features/editeur-inline.md @@ -59,6 +59,8 @@ d'édition mobile `.me-ribbon`, ancré à la modale, conserve `pointer-events: a | ✓ (Sauvegarder) | sauvegarde puis `closeEditor()` → retour à la lecture (contenu relu depuis le disque) | | ✕ (Annuler) / **Échap** | `closeEditor()` → retour à la lecture (les modifications non sauvegardées sont abandonnées, comme avant) | | Ouverture d'un autre fichier (`renderFile()`) | garde-fou : `detachInlineEditor()` libère la session (destroy CodeMirror/Yjs, retrait de l'iframe Forge) avant le rendu de la lecture | +| Activation d'un onglet (`TabManager.activate`, `PaneTabManager.activate`) | même garde-fou **avant** le placeholder de chargement : ces fonctions vident la zone de contenu, ce qui détruirait le DOM de l'éditeur | +| Événement SSE `index_updated` sur le fichier affiché | `reloadExternalWrite()` recharge le **tampon de l'éditeur** depuis le disque au lieu de re-rendre la vue lecture (sans quoi la modification externe détruisait la session) | | `forge-close` (message de l'iframe) | route vers `closeEditor()` — plus de manipulation manuelle de la modale dans `sync.js` | **Repli overlay** : si la cible n'est pas le document affiché (fichier venant d'être créé via la diff --git a/frontend/js/editor-inline.js b/frontend/js/editor-inline.js index 7e5678f..7b07331 100644 --- a/frontend/js/editor-inline.js +++ b/frontend/js/editor-inline.js @@ -69,6 +69,17 @@ export function isInlineEditorActive() { return !!(container && container.classList.contains(INLINE_CONTAINER_CLASS)); } +/** + * Query an element inside the editor container, wherever it currently lives + * (modal or content area). Use it instead of `modal.querySelector(...)` for the + * header/footer/brand, which move with the container in inline mode (#93). + */ +export function queryEditor(selector) { + const container = getEditorContainer(); + const scope = container || getEditorModal(); + return scope ? scope.querySelector(selector) : null; +} + /** * Move the editor container into `area`, replacing the rendered document. * @returns {boolean} true when the container was mounted inline. diff --git a/frontend/js/pane-manager.js b/frontend/js/pane-manager.js index ade3be5..a61f57d 100644 --- a/frontend/js/pane-manager.js +++ b/frontend/js/pane-manager.js @@ -10,7 +10,7 @@ */ import { state } from './state.js'; import { api } from './auth.js'; -import { escapeHtml, getFileIcon, safeCreateIcons } from './utils.js'; +import { escapeHtml, getFileIcon, safeCreateIcons, detachInlineEditor } from './utils.js'; const PANE_MAX = 4; @@ -154,6 +154,9 @@ function createPaneTabManager(paneId) { async activate(tabId) { if (this._activeTabId === tabId && this._tabs.length > 0) return; + // #93 — an inline edition session owns this pane's content area: release + // it before the load placeholder / read view wipes it off the DOM. + detachInlineEditor(); if (this._activeTabId && this._tabCache[this._activeTabId]) this._saveCurrentTabState(); diff --git a/frontend/js/sync.js b/frontend/js/sync.js index c43d594..653e7a0 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -6,7 +6,7 @@ import { api, AuthManager } from './auth.js'; import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js'; import { loadRecentFiles, initAboutModal } from './config.js'; import { openFile } from './viewer.js'; -import { closeEditor } from './utils.js'; +import { closeEditor, reloadExternalWrite } from './utils.js'; import { initOffline, syncFileIndexFromServer } from './offline.js'; import { initThemes } from './themes.js'; @@ -166,7 +166,11 @@ export const IndexUpdateManager = (() => { if (state.currentVault && state.currentPath) { const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath); if (changed) { - openFile(state.currentVault, state.currentPath); + // #93 — an inline edition session (or Forge) owns the content area: + // re-render the read view below it would destroy the editor mid- + // session. Reload the editor buffer from disk instead (same path as + // an external AI write). + reloadExternalWrite(state.currentVault, state.currentPath); } } } catch (err) { diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 241ab90..92d5cae 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -2,7 +2,7 @@ import { api, AuthManager, initLoginForm } from './auth.js'; import { state } from './state.js'; import { openFile, showWelcome, renderFile, el } from './viewer.js'; -import { safeCreateIcons, getFileIcon, escapeHtml } from './utils.js'; +import { safeCreateIcons, getFileIcon, escapeHtml, detachInlineEditor } from './utils.js'; import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js'; import { GraphViewManager } from './graph.js'; import { DashboardConflictsWidget } from './dashboard.js'; @@ -2014,7 +2014,11 @@ export const TabManager = { /** Activate a specific tab */ async activate(tabId) { if (this._activeTabId === tabId && this._tabs.length > 0) return; - + + // #93 — an inline edition session owns this content area: release it before + // the load placeholder / read view wipes it off the DOM. + detachInlineEditor(); + // Save current tab state if (this._activeTabId && this._tabCache[this._activeTabId]) { this._saveCurrentTabState(); diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 2f7a85d..a41b6d8 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -6,9 +6,11 @@ import { createAIToolbar } from './ai.js'; import { startCollab, stopCollab } from './collab.js'; import { getInlineHostArea, + getEditorContainer, mountEditorInline, unmountEditorInline, isInlineEditorActive, + queryEditor, } from './editor-inline.js'; // --------------------------------------------------------------------------- @@ -372,9 +374,9 @@ async function openEditor(vaultName, filePath) { const bodyEl = document.getElementById("editor-body"); // Restore header/footer (may be hidden by Forge) - var headerEl = modal.querySelector(".editor-header"); - var footerEl = modal.querySelector(".editor-footer"); - var container = modal.querySelector(".editor-container"); + var headerEl = queryEditor(".editor-header"); + var footerEl = queryEditor(".editor-footer"); + var container = getEditorContainer(); if (headerEl) headerEl.style.display = ""; if (footerEl) footerEl.style.display = ""; if (container) container.style.maxHeight = ""; @@ -580,14 +582,6 @@ function closeEditor() { const modal = document.getElementById("editor-modal"); if (!modal) return; modal.classList.remove("active"); - // Restore brand text - var brand = modal.querySelector('.editor-brand'); - if (brand) brand.textContent = 'ObsiGate'; - // Restore header/footer (may be hidden by Forge) - var headerEl = modal.querySelector(".editor-header"); - var footerEl = modal.querySelector(".editor-footer"); - if (headerEl) headerEl.style.display = ""; - if (footerEl) footerEl.style.display = ""; stopCollab(); if (state.editorView) { state.editorView.destroy(); @@ -598,9 +592,16 @@ function closeEditor() { // Remove forge iframe if present var forgeFrame = document.getElementById("forge-iframe"); if (forgeFrame) forgeFrame.remove(); - // #93 — give the editor container back to the modal; an inline session also - // releases the content area, whose read view must be re-rendered. + // #93 — hand the editor container back to the modal BEFORE restoring the + // header/footer/brand: they live inside the container and therefore travel + // with it in inline mode (a `modal.querySelector` would miss them). const host = unmountEditorInline(); + var brand = queryEditor('.editor-brand'); + if (brand) brand.textContent = 'ObsiGate'; + var headerEl = queryEditor(".editor-header"); + var footerEl = queryEditor(".editor-footer"); + if (headerEl) headerEl.style.display = ""; + if (footerEl) footerEl.style.display = ""; _invalidateActiveTabCache(vault, path); state.editorVault = null; state.editorPath = null; diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 69732d2..0b9b37d 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -2,7 +2,7 @@ import { api, AuthManager } from './auth.js'; import { state } from './state.js'; import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js'; -import { isInlineEditorActive } from './editor-inline.js'; +import { isInlineEditorActive, queryEditor } from './editor-inline.js'; import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js'; import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js'; import { AutocompleteDropdown, performAdvancedSearch } from './search.js'; @@ -768,8 +768,9 @@ export function renderFile(data) { state.editorVault = data.vault; state.editorPath = data.path; } - // Update header: show "Forge" brand + filename - var brand = modal.querySelector(".editor-brand"); + // Update header: show "Forge" brand + filename (the header travels with the + // container in inline mode — query it wherever it lives, #93) + var brand = queryEditor(".editor-brand"); var titleInp = document.getElementById("editor-title-input"); var fileInp = document.getElementById("editor-filename-input"); if (brand) brand.textContent = t("viewer.forge_brand"); diff --git a/package.json b/package.json index b3ee886..e06fd99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.3.2", + "version": "2.3.3", "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 35401c7..b86b09d 100644 --- a/tests/frontend/editor-inline.test.mjs +++ b/tests/frontend/editor-inline.test.mjs @@ -67,6 +67,7 @@ const { isInlineEditorActive, mountEditorInline, unmountEditorInline, + queryEditor, } = mod; let testCount = 0; @@ -161,6 +162,17 @@ test("unmount without an inline session is a no-op", () => { assert.equal(container().parentElement, modal()); }); +test("queryEditor follows the container (modal → content area)", () => { + reset(); + assert.equal(queryEditor(".editor-brand").textContent, "ObsiGate"); + mountEditorInline(area()); + assert.ok(queryEditor(".editor-brand"), "brand reachable while mounted inline"); + assert.equal(document.getElementById("editor-modal").querySelector(".editor-brand"), null, "brand really left the overlay"); + unmountEditorInline(); + assert.ok(document.getElementById("editor-modal").querySelector(".editor-brand"), "brand back in the overlay"); + reset(); +}); + test("host area follows the active pane in split view", () => { reset(); const paneArea = document.createElement("div"); @@ -193,12 +205,37 @@ const forgeSrc = read("frontend", "editor-poc.html"); console.log("Inline edition (#93) — wiring"); +test("utils.js / viewer.js import every editor-inline helper they call", () => { + const helpers = ["getEditorContainer", "getInlineHostArea", "isInlineEditorActive", "mountEditorInline", "unmountEditorInline", "queryEditor"]; + const strip = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + for (const [name, raw] of [["utils.js", utilsSrc], ["viewer.js", viewerSrc]]) { + const src = strip(raw); + const marker = "from './editor-inline.js';"; + const end = raw.indexOf(marker); + const start = end === -1 ? -1 : raw.lastIndexOf("import {", end); + const imported = + end === -1 || start === -1 + ? [] + : raw + .slice(start + "import {".length, end) + .replace(/\}/g, "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + for (const helper of helpers) { + if (new RegExp(`\\b${helper}\\(`).test(src)) { + assert.ok(imported.includes(helper), `${name} calls ${helper}() without importing it`); + } + } + } +}); + test("index.html gives the editor container a stable id", () => { assert.match(indexSrc, /
/); }); test("viewer.js mounts the editor inline when rendering is taken over", () => { - assert.match(viewerSrc, /import \{ isInlineEditorActive \} from '\.\/editor-inline\.js';/); + assert.ok(viewerSrc.includes("import { isInlineEditorActive, queryEditor } from './editor-inline.js';")); const guard = viewerSrc.match(/export function renderFile\(data\) \{([\s\S]*?)\n const area = getContentArea\(\);/); assert.ok(guard, "renderFile head not found"); assert.match(guard[1], /if \(isInlineEditorActive\(\)\) detachInlineEditor\(\);/); @@ -206,6 +243,8 @@ test("viewer.js mounts the editor inline when rendering is taken over", () => { test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => { assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/); + assert.ok(viewerSrc.includes('var brand = queryEditor(".editor-brand");'), "header query must follow the container"); + assert.equal(viewerSrc.includes('modal.querySelector(".editor-brand")'), false, "modal-scoped brand query would break inline mode"); assert.match( viewerSrc, /iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/, @@ -245,12 +284,32 @@ test("utils.js reloads the displayed document after an AI write", () => { }); test("sync.js forge-close goes through the shared close path", () => { - assert.match(syncSrc, /import \{ closeEditor \} from '\.\/utils\.js';/); + 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 \}/); assert.ok(handler, "forge-close handler not found"); assert.match(handler[1], /closeEditor\(\);/); }); +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"); + assert.ok(sse[1].includes("reloadExternalWrite(state.currentVault, state.currentPath);"), "SSE refresh must not re-render the read view"); + assert.ok(!/^\s*openFile\(state\.currentVault/m.test(sse[1]), "openFile must not be called directly here"); +}); + +test("tab activation releases an inline edition session first", () => { + const uiActivate = read("frontend", "js", "ui.js").match(/async activate\(tabId\) \{([\s\S]*?)\n \},/); + assert.ok(uiActivate, "TabManager.activate not found"); + assert.match(uiActivate[1], /detachInlineEditor\(\);/); +}); + +test("pane activation releases an inline edition session first", () => { + const paneSrc = read("frontend", "js", "pane-manager.js"); + const paneActivate = paneSrc.match(/async activate\(tabId\) \{([\s\S]*?)\n const cache/); + assert.ok(paneActivate, "PaneTabManager.activate not found"); + assert.match(paneActivate[1], /detachInlineEditor\(\);/); +}); + test("bookslm.js reports the edited document and the assistant writes", () => { assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/); assert.match(bookslmSrc, /if \(editing\) ctx\.editing = editing;/); @@ -281,7 +340,6 @@ test("style.css lets the inline editor fill the content area", () => { assert.match(fill[1], /height: auto;/, "must win over the mobile 100vh rule"); assert.match(fill[1], /max-width: none;/); }); - // ── BUG-045: one scroll surface only in the CodeMirror editor ── test("style.css: #editor-body scrolls through the CodeMirror scroller only", () => { const body = cssSrc.match(/\.editor-body\.editor-body-cm \{([^}]*)\}/);