From e3df4bbf008b4dce090a004a668eaaa603f8a648 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 15 Sep 2026 23:40:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(editeur):=20#93=20edition=20inline=20?= =?UTF-8?q?=E2=80=94=20Editer/Forge=20remplacent=20la=20vue=20lecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le conteneur d'edition (#editor-container, CodeMirror ou iframe Forge) est deplace dans la zone de contenu du document (#content-area ou pane active) au lieu de l'overlay plein ecran : le mode edition remplace la vue lecture. L'overlay reste monte (transparent, pointer-events: none) car le ruban d'edition mobile y est ancre ; il ne sert plus que de repli quand la cible n'est pas le document affiche. Garde-fou dans renderFile() pour liberer proprement la session (destroy CodeMirror/Yjs, retrait de l'iframe Forge) ; forge-close passe par closeEditor() ; cache d'onglet invalide au retour en lecture. Assistant IA : app_context.editing annonce le document en cours d'edition (frontend bookslm.js + backend bookslm.py), et chaque ecriture d'outil (edit_file, append_to_file, create_file, restore_backup) recharge le document affiche (obsigate:file-written) — tampon CodeMirror remplace avec auto-save neutralisee, parent-reload pour l'iframe Forge, re-rendu de la vue lecture sinon. Tests : tests/frontend/editor-inline.test.mjs (19), tests/test_bookslm.py::TestGeneralPrompt (3 nouveaux). Fiche : docs/features/editeur-inline.md. --- .gitea/workflows/ci.yml | 4 +- CHANGELOG.md | 22 ++ backend/bookslm.py | 11 + docs/ROADMAP.md | 5 +- docs/features/editeur-inline.md | 125 +++++++++++ frontend/editor-poc.html | 10 + frontend/index.html | 14 +- frontend/js/bookslm.js | 45 ++++ frontend/js/editor-inline.js | 115 ++++++++++ frontend/js/sync.js | 20 +- frontend/js/utils.js | 191 ++++++++++++++++- frontend/js/viewer.js | 21 +- frontend/style.css | 41 ++++ tests/frontend/editor-inline.test.mjs | 289 ++++++++++++++++++++++++++ tests/frontend/unit.test.mjs | 1 + tests/test_bookslm.py | 39 ++++ 16 files changed, 920 insertions(+), 33 deletions(-) create mode 100644 docs/features/editeur-inline.md create mode 100644 frontend/js/editor-inline.js create mode 100644 tests/frontend/editor-inline.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4ec2253..2595f69 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Frontend unit tests run: node tests/frontend/unit.test.mjs - - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop) + - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition) run: | cd tests/frontend if [ -d node_modules ]; then @@ -52,6 +52,7 @@ jobs: node semantic-search.test.mjs node desktop.test.mjs node toolbar-order.test.mjs + node editor-inline.test.mjs else echo "tests/frontend/node_modules missing - installing jsdom" npm install --no-audit --no-fund --silent @@ -65,6 +66,7 @@ jobs: node semantic-search.test.mjs node desktop.test.mjs node toolbar-order.test.mjs + node editor-inline.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cfbc1..66434ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,28 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Tests : `tests/frontend/ai.test.mjs` (82), `tests/test_bookslm.py::TestToolEventSources` (4), `tests/test_web_tools.py` (10). +### Modifié + +- **#93 Édition inline — « Editer » et « Forge » remplacent la vue lecture** : le conteneur + d'édition (`#editor-container`, CodeMirror ou iframe Forge) est déplacé **dans la zone de + contenu du document** (`#content-area`, ou le panneau actif en vue fractionnée) au lieu + d'être affiché dans l'overlay plein écran : le mode édition **remplace** le document en mode + lecture (bandeau, métadonnées, tags et contenu). L'overlay reste monté mais neutralisé + (transparent, sans capture des clics, `z-index` conservé) car le ruban d'édition mobile y est + ancré. Retour à la lecture par ✓ (sauvegarde), ✕, Échap ou l'ouverture d'un autre fichier — + un garde-fou dans `renderFile()` libère proprement la session (destruction CodeMirror/Yjs, + retrait de l'iframe Forge) ; l'overlay n'est conservé qu'en repli, quand la cible n'est pas le + document affiché (ex. fichier créé depuis la palette). Conséquence directe : l'éditeur ne + recouvre plus le panneau de l'assistant IA, qui peut donc mettre le document à jour **sous + les yeux de l'utilisateur** — `app_context.editing` annonce au modèle le document en cours + d'édition (et sa surface), et toute écriture d'outil (`edit_file`, `append_to_file`, + `create_file`, `restore_backup`) recharge le document affiché depuis le disque + (`obsigate:file-written`) : le tampon de l'éditeur est rafraîchi au lieu d'écraser la + modification de l'assistant par son auto-sauvegarde, et l'iframe Forge reçoit + `parent-reload`. Aucun identifiant DOM n'a changé (autosave, collab Yjs, barre IA, aperçu + Mermaid, Forge intacts). Fiche : [docs/features/editeur-inline.md](./docs/features/editeur-inline.md). + Tests : `tests/frontend/editor-inline.test.mjs` (19), `tests/test_bookslm.py::TestGeneralPrompt` (3 nouveaux). + ### Corrigé - **#91 — Bulle utilisateur trop étroite & ancre d'envoi retardée** : le plafond de diff --git a/backend/bookslm.py b/backend/bookslm.py index 8c5f153..832d09c 100644 --- a/backend/bookslm.py +++ b/backend/bookslm.py @@ -529,6 +529,17 @@ def _format_app_context(app_context: dict[str, Any] | None, recent_files: list[d if rendered_docs: lines.append("- Documents ouverts dans les onglets/panneaux : " + ", ".join(rendered_docs)) + editing = app_context.get("editing") + if isinstance(editing, dict) and editing.get("path"): + surface = "Forge" if editing.get("surface") == "forge" else "l'éditeur" + location = f"{editing['path']} (vault {editing['vault']})" if editing.get("vault") else str(editing["path"]) + lines.append( + f"- Document en cours d'édition dans {surface} : {location} — c'est le document affiché à la " + "place de la vue lecture. Pour le mettre à jour, utilise les outils d'écriture " + "(`edit_file`, `append_to_file`) : la modification est rechargée automatiquement dans " + "l'éditeur et la vue lecture dès l'exécution de l'outil." + ) + query = app_context.get("search_query") if query: total = app_context.get("search_total") diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 622db91..4c44ac0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-14 +> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-15 > **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)** @@ -188,6 +188,7 @@ | 83 | Barre d'outils d'édition mobile — ruban style Obsidian Android | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) | | 91 | Assistant IA — Zone de discussion façon Notion : post ancré en haut, fournisseur/modèle discret & barre d'actions | 2.3.0 | [features/ai-assistant-conversation-ux.md](./features/ai-assistant-conversation-ux.md) | +| 93 | Édition inline — « Editer » et « Forge » remplacent la vue lecture (assistant IA qui met le document à jour) | 2.3.0 | [features/editeur-inline.md](./features/editeur-inline.md) | --- @@ -195,7 +196,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–91 | ~107 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93 | ~108 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours | diff --git a/docs/features/editeur-inline.md b/docs/features/editeur-inline.md new file mode 100644 index 0000000..9ac3815 --- /dev/null +++ b/docs/features/editeur-inline.md @@ -0,0 +1,125 @@ +# #93 — Édition inline : « Editer » et « Forge » remplacent la vue lecture + +> **Statut :** 🟢 livré (en attente de vérification utilisateur) +> **Version :** 2.3.0 +> **Composants :** `frontend/js/editor-inline.js` (nouveau), `frontend/js/utils.js`, +> `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/js/bookslm.js`, +> `frontend/editor-poc.html`, `frontend/index.html`, `frontend/style.css`, +> `backend/bookslm.py` +> **Tests :** `tests/frontend/editor-inline.test.mjs` (19), +> `tests/test_bookslm.py::TestGeneralPrompt` (7) + +## Contexte + +Le mode édition (bouton **Editer**, CodeMirror) et **Forge** (éditeur avancé dans une iframe) +s'ouvraient dans `#editor-modal` : un overlay `position: fixed` plein écran. Le document restait +rendu **dessous**, mais invisible, et l'overlay recouvrait le panneau de l'assistant IA +(`.bookslm-panel`, `z-index: 100` contre `1000` pour la modale). Conséquences : + +- le document n'était plus consultable pendant l'édition (pas de relecture côte à côte) ; +- l'assistant IA était inutilisable pendant l'édition : son action « Ajouter » (qui insère dans + `state.editorView`) exigeait une session d'édition ouverte… que l'utilisateur ne pouvait pas + regarder en même temps que l'assistant. + +## Conception + +### 1. Bascule du conteneur d'édition dans la zone de lecture + +`frontend/js/editor-inline.js` (module autonome, sans dépendance) déplace le conteneur +`#editor-container` — qui contient le CodeMirror **ou** l'iframe Forge — de l'overlay vers la +zone de contenu : + +``` +#content-area (ou .pane-content de la pane active) ← le document en lecture + └── .editor-container.editor-inline ← l'éditeur, à la place du document +``` + +| Fonction | Rôle | +|---|---| +| `getEditorContainer()` / `getEditorModal()` | résolution par id (`#editor-container`, `#editor-modal`) | +| `getInlineHostArea()` | zone de lecture visée : override de rendu, pane active en vue fractionnée, sinon `#content-area` | +| `isInlineEditorActive()` | une session occupe-t-elle une zone de contenu ? | +| `mountEditorInline(area)` | `area.replaceChildren(container)` + classes `editor-inline` / `editor-inline-host` / `editor-inline-mode` | +| `unmountEditorInline()` | conteneur rendu à la modale, zone vidée, renvoie la zone à re-rendre | + +Aucun identifiant DOM n'est modifié (`#editor-body`, `#editor-save`, `#editor-title-input`…) : +CodeMirror, l'auto-sauvegarde (2 s), la collaboration Yjs, la barre IA, l'aperçu Mermaid et +l'iframe Forge continuent de fonctionner sans réécriture. Seul l'**hôte** change. + +L'overlay reste monté et garde la classe `active` (signal d'état utilisé par l'auto-save, le +raccourci Ctrl+J, la hauteur d'éditeur mobile…), mais passe en classe +`editor-inline-mode` : transparent, sans fond ni padding, `pointer-events: none` (le ruban +d'édition mobile `.me-ribbon`, ancré à la modale, conserve `pointer-events: auto`). + +### 2. Cycle de vie + +| Action | Effet | +|---|---| +| **Editer** / **Forge** | `activateInlineEditor(vault, path)` → bascule inline **si** la cible est le document affiché dans la zone de contenu | +| ✓ (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 | +| `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 +palette, par exemple), la modale plein écran est conservée. Le mode inline exige que la zone de +lecture soit celle du fichier édité — c'est ce qui garantit que « remplacer » signifie bien +remplacer *ce* document. + +L'invalidation du cache d'onglet (`_invalidateActiveTabCache`) évite qu'un aller-retour +d'onglets réaffiche le HTML **d'avant** l'édition. + +### 3. Assistant IA — document mis à jour sous les yeux de l'utilisateur + +L'éditeur n'occupe plus le panneau de l'assistant : les deux surfaces cohabitent. Trois +ajouts rendent la mise à jour effective de bout en bout : + +1. **`app_context.editing`** — `bookslm.js::_editingDocument()` envoie + `{vault, path, surface: "editor"|"forge"}` quand une session d'édition est ouverte ; + `backend/bookslm.py::_format_app_context()` l'annonce dans le prompt Général + (« Document en cours d'édition dans … ») et indique au modèle d'utiliser les outils + d'écriture pour le mettre à jour. +2. **`obsigate:file-written`** — à chaque événement SSE `tool` réussi d'un outil d'écriture + (`edit_file`, `append_to_file`, `create_file`, `restore_backup`), `bookslm.js` diffuse un + événement `{vault, path}`. +3. **Rechargement** — `utils.js::reloadExternalWrite()` : + - fichier ouvert dans l'éditeur inline → le tampon CodeMirror est **remplacé** par le contenu + du disque (l'auto-sauvegarde de ce remplacement est neutralisée, sinon elle réécrivait + l'ancien texte par-dessus la modification de l'IA) ; + - fichier ouvert dans Forge → message `parent-reload` à l'iframe (`editor-poc.html`), qui + relit le fichier et abandonne son tampon périmé ; + - fichier simplement affiché en lecture → re-rendu depuis le disque. + +Sans ce point 3, une édition par l'IA aurait été silencieusement écrasée par l'auto-sauvegarde de +l'éditeur deux secondes plus tard. + +### 4. Feuille de style + +`frontend/style.css` : `.content-area.editor-inline-host` devient une colonne flex sans padding, +et `.editor-container.editor-inline` la remplit (`flex: 1`, `height: auto` — pour battre le +`height: 100vh` de la media query mobile par spécificité, `max-width/max-height: none`, sans +bordure ni ombre). L'en-tête d'édition (titre, nom de fichier, pastille d'état, ✓/✕/🗑) sert +donc de barre d'outils du document, et le corps occupe la zone de lecture. + +## Limites connues + +- Les outils de **renommage / déplacement** de l'IA (`rename_file`, `move_path`) ne déclenchent + pas de rechargement : après un renommage, le document redevient accessible via l'arborescence. +- La collaboration Yjs reste liée à l'ouverture de l'éditeur : un changement d'onglet pendant + une session collaborative la termine (comportement identique à celui de la fermeture). +- Le mode inline suppose un document affiché correspondant à la cible ; les flux « création de + fichier » passent donc toujours par la modale. + +## Vérification + +```bash +node tests/frontend/editor-inline.test.mjs # 19 tests (DOM + câblage) +node tests/frontend/validate-imports.mjs +node tests/frontend/unit.test.mjs +.venv/Scripts/python.exe -m pytest tests/test_bookslm.py -k GeneralPrompt +``` + +Contrôle manuel sur l'instance de test (`http://localhost:2020`, `admin` / `test123`) : ouvrir un +document markdown → **Editer** (le document est remplacé par l'éditeur, bandeau/onglets toujours +visibles, panneau assistant cliquable) → **Forge** depuis la lecture (iframe plein cadre) → +✓ / ✕ / Échap → retour en lecture avec le contenu à jour. diff --git a/frontend/editor-poc.html b/frontend/editor-poc.html index 18d7e53..54647c7 100644 --- a/frontend/editor-poc.html +++ b/frontend/editor-poc.html @@ -1450,6 +1450,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig window.addEventListener('message', function(e) { if (!e.data || !e.data.type) return; if (e.data.type === 'parent-save') { forceSave(); } + // #93 — the parent reloads the document after an external write (AI + // assistant edit_file / append_to_file / create_file): re-read from disk + // and drop the stale local buffer that would otherwise be autosaved back + // over the assistant's change. + if (e.data.type === 'parent-reload') { + clearTimeout(saveTimer); + isDirty = false; + loadFile(); + updateSaveDot(); + } if (e.data.type === 'parent-delete') { if (!fileVault || !filePath) return; if (!confirm('Supprimer definitivement ' + filePath + ' ?')) return; diff --git a/frontend/index.html b/frontend/index.html index 2636ad6..018b2a6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1211,7 +1211,13 @@ apercu rapide. Cliquez sur Forge dans la barre d'outils pour l'editeur - avance.Editer et + Forge remplacent + le document en mode lecture + (retour par ✓, ✕ ou + Echap) : le + panneau de l'assistant IA reste + accessible a cote. @@ -1370,9 +1376,11 @@ - +
-
+
ObsiGate ({ vault: d.vault, path: d.path })); } + // #93 — Live edition session: the document currently replaced by the + // editor/Forge surface. Knowing it lets the assistant target "le document + // que je suis en train d'éditer" and update it in place. + const editing = this._editingDocument(); + if (editing) ctx.editing = editing; + const query = state.advancedSearchLastQuery; if (query) { ctx.search_query = query; @@ -803,6 +809,43 @@ class BooksLM { return ctx; } + /** + * #93 — The document currently being edited, i.e. the one the editor/Forge + * surface replaced the read view with, or null. Sent as `app_context.editing` + * so the assistant can target "le document que je suis en train d'éditer". + */ + _editingDocument() { + try { + const modal = document.getElementById('editor-modal'); + if (!modal || !modal.classList.contains('active')) return null; + const vault = state.editorVault; + const path = state.editorPath; + if (!vault || !path) return null; + return { vault, path, surface: document.getElementById('forge-iframe') ? 'forge' : 'editor' }; + } catch (e) { + return null; + } + } + + /** + * #93 — A write tool just modified a vault file. Notify the UI so the + * displayed document is reloaded from disk: otherwise the read view keeps the + * stale content, and an open editor buffer autosaves the old text back over + * the assistant's change (see utils.reloadExternalWrite). + */ + _notifyFileWritten(data) { + if (!data || data.ok === false) return; + const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup']; + if (WRITE_TOOLS.indexOf(data.name) === -1) return; + const args = data.arguments || {}; + if (!args.vault || !args.path) return; + window.dispatchEvent( + new CustomEvent('obsigate:file-written', { + detail: { vault: args.vault, path: args.path, tool: data.name }, + }), + ); + } + // ── Rendering ─────────────────────────────────────────────────────── _render() { @@ -2575,6 +2618,8 @@ class BooksLM { step: data.step || null, sources: Array.isArray(data.sources) ? data.sources : [], }); + // #93 — a vault write must be reflected in the displayed document. + this._notifyFileWritten(data); this._setActivity('working', t('ai.activity_tool', { name: data.name })); return; } diff --git a/frontend/js/editor-inline.js b/frontend/js/editor-inline.js new file mode 100644 index 0000000..7e5678f --- /dev/null +++ b/frontend/js/editor-inline.js @@ -0,0 +1,115 @@ +/** + * ObsiGate — Inline editor host (ROADMAP #93) + * + * The edition surface (`#editor-container`, containing the CodeMirror editor or + * the Forge iframe) used to live inside the full-screen `#editor-modal` overlay, + * which covered both the document read view and the AI assistant sidebar. + * + * This module moves that container **into the content area of the document + * being read** so editing replaces the read view instead of overlaying it: + * + * `#content-area` (or the active pane `.pane-content` in split view) + * └── .editor-container.editor-inline ← the editor, replacing the document + * + * Keeping the same DOM nodes (same ids: `#editor-body`, `#editor-save`, …) means + * CodeMirror, autosave, Yjs collaboration, the AI toolbar and the Forge iframe + * keep working untouched — only their host moves. + * + * Classes: + * `.editor-inline` — set on the container while it hosts an inline session + * `.editor-inline-host` — set on the content area it replaces + * `#editor-modal.editor-inline-mode` — the overlay is neutralised (transparent, + * zero-size, no scroll) but keeps `active`, so the mobile + * ribbon anchored to it (`.me-ribbon`) still works. + */ + +export const INLINE_CONTAINER_CLASS = 'editor-inline'; +export const INLINE_HOST_CLASS = 'editor-inline-host'; +export const INLINE_MODAL_CLASS = 'editor-inline-mode'; + +/** The editor overlay element (`#editor-modal` when present). */ +export function getEditorModal() { + return document.getElementById('editor-modal'); +} + +/** + * The editor container, wherever it currently lives (modal or content area). + * Falls back to a class lookup so the helper also works on a DOM where the id + * was not set (tests, older markup). + */ +export function getEditorContainer() { + return ( + document.getElementById('editor-container') || + document.querySelector('.editor-container.' + INLINE_CONTAINER_CLASS) + ); +} + +/** + * Content area that should host the inline editor: the pane currently being + * read. Mirrors viewer.js `getContentArea()` (multi-pane aware). + */ +export function getInlineHostArea() { + if (typeof window !== 'undefined') { + if (window._activePaneContentArea) return window._activePaneContentArea; + if ( + window.PaneManager && + typeof window.PaneManager.isSplit === 'function' && + window.PaneManager.isSplit() && + typeof window.getActiveContentArea === 'function' + ) { + return window.getActiveContentArea(); + } + } + return document.getElementById('content-area'); +} + +/** True when an inline edition session currently owns a content area. */ +export function isInlineEditorActive() { + const container = getEditorContainer(); + return !!(container && container.classList.contains(INLINE_CONTAINER_CLASS)); +} + +/** + * Move the editor container into `area`, replacing the rendered document. + * @returns {boolean} true when the container was mounted inline. + */ +export function mountEditorInline(area) { + const container = getEditorContainer(); + const modal = getEditorModal(); + if (!container || !modal || !area) return false; + // Already hosted by this very area — nothing to move. + if (container.parentElement === area) { + container.classList.add(INLINE_CONTAINER_CLASS); + area.classList.add(INLINE_HOST_CLASS); + modal.classList.add(INLINE_MODAL_CLASS); + return true; + } + area.classList.add(INLINE_HOST_CLASS); + area.replaceChildren(container); + container.classList.add(INLINE_CONTAINER_CLASS); + modal.classList.add(INLINE_MODAL_CLASS); + return true; +} + +/** + * Move the editor container back into the modal and clear the inline classes. + * The host area is emptied (its read view is gone) and returned so the caller + * can re-render the document. + * @returns {HTMLElement|null} the content area that must be re-rendered. + */ +export function unmountEditorInline() { + const container = getEditorContainer(); + const modal = getEditorModal(); + if (!container || !modal) return null; + const parent = container.parentElement; + const host = parent && parent !== modal ? parent : null; + container.classList.remove(INLINE_CONTAINER_CLASS); + if (host) { + host.classList.remove(INLINE_HOST_CLASS); + if (typeof host.replaceChildren === 'function') host.replaceChildren(); + else host.innerHTML = ''; + } + modal.classList.remove(INLINE_MODAL_CLASS); + modal.appendChild(container); + return host; +} diff --git a/frontend/js/sync.js b/frontend/js/sync.js index 23f232e..c43d594 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -6,6 +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 { initOffline, syncFileIndexFromServer } from './offline.js'; import { initThemes } from './themes.js'; @@ -636,22 +637,9 @@ export function init() { } } if (e.data.type === 'forge-close') { - var modal = document.getElementById('editor-modal'); - var forgeFrame = document.getElementById('forge-iframe'); - if (forgeFrame) forgeFrame.remove(); - if (modal) modal.classList.remove('active'); - // Restore brand text - var brand = modal.querySelector('.editor-brand'); - if (brand) brand.textContent = 'ObsiGate'; - // Restore header/footer - var headerEl = modal.querySelector('.editor-header'); - var footerEl = modal.querySelector('.editor-footer'); - if (headerEl) headerEl.style.display = ''; - if (footerEl) footerEl.style.display = ''; - // Refresh current file content - if (state.currentVault && state.currentPath) { - import('./viewer.js').then(function(m) { m.openFile(state.currentVault, state.currentPath); }); - } + // #93 — shared close path: hands the editor container back to the modal + // (inline mode) and re-renders the document read view. + closeEditor(); } }); } diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 8ed913a..2f7a85d 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -4,6 +4,67 @@ import { openFile, showWelcome } from './viewer.js'; import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js'; import { createAIToolbar } from './ai.js'; import { startCollab, stopCollab } from './collab.js'; +import { + getInlineHostArea, + mountEditorInline, + unmountEditorInline, + isInlineEditorActive, +} from './editor-inline.js'; + +// --------------------------------------------------------------------------- +// Inline edition (#93) +// --------------------------------------------------------------------------- + +/** + * Set just before a programmatic document replacement (external AI write) so + * the autosave update listener does not schedule a redundant save of the + * freshly reloaded content. + */ +let suppressAutoSaveOnce = false; + +/** + * Inline edition: the editor replaces the document read view in the content + * area instead of covering it with the `#editor-modal` overlay. Only possible + * when the editor targets the document currently read (the pane area is what + * gets taken over); otherwise the overlay is kept as a fallback (e.g. a file + * just created from the command palette and not opened in the viewer yet). + * + * @returns {boolean} true when the editor is hosted inline. + */ +export function activateInlineEditor(vaultName, filePath) { + if (!vaultName || !filePath) return false; + if (state.currentVault !== vaultName || state.currentPath !== filePath) return false; + const area = getInlineHostArea(); + if (!area) return false; + return mountEditorInline(area); +} + +/** + * Drop an inline edition session without re-rendering the document: used when + * the read view is about to be re-rendered for another file (tab switch, + * sidebar click, wikilink), which owns the same content area. + */ +export function detachInlineEditor() { + if (!isInlineEditorActive()) return; + const modal = document.getElementById("editor-modal"); + stopCollab(); + if (state.editorView) { + state.editorView.destroy(); + state.editorView = null; + } + state.fallbackEditorEl = null; + clearTimeout(window._obsigateAutoSaveTimer); + var forgeFrame = document.getElementById("forge-iframe"); + if (forgeFrame) forgeFrame.remove(); + if (modal) { + modal.classList.remove("active"); + var brand = modal.querySelector('.editor-brand'); + if (brand) brand.textContent = 'ObsiGate'; + } + unmountEditorInline(); + state.editorVault = null; + state.editorPath = null; +} // --------------------------------------------------------------------------- // File extension → Lucide icon mapping @@ -318,6 +379,11 @@ async function openEditor(vaultName, filePath) { if (footerEl) footerEl.style.display = ""; if (container) container.style.maxHeight = ""; + // #93 — Inline mode: when the edited file is the document currently read, the + // editor container takes the place of the read view (no overlay covering the + // document and the AI assistant panel). + activateInlineEditor(vaultName, filePath); + filenameInput.value = filePath.split("/").pop(); statFile.textContent = vaultName + "/" + filePath; @@ -361,6 +427,12 @@ async function openEditor(vaultName, filePath) { // Auto-save: debounced save after 2s of inactivity EditorView.updateListener.of((update) => { if (update.docChanged) { + // Skip the autosave scheduled by a programmatic reload of the + // document (external write by the AI assistant, #93). + if (suppressAutoSaveOnce) { + suppressAutoSaveOnce = false; + return; + } clearTimeout(window._obsigateAutoSaveTimer); window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000); // Mark dirty @@ -506,26 +578,106 @@ function closeEditor() { const vault = state.editorVault; const path = state.editorPath; 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(); state.editorView = null; } state.fallbackEditorEl = null; - // Remove forge iframe if present + refresh current file + clearTimeout(window._obsigateAutoSaveTimer); + // Remove forge iframe if present var forgeFrame = document.getElementById("forge-iframe"); - if (forgeFrame) { - forgeFrame.remove(); - if (state.currentVault && state.currentPath) openFile(state.currentVault, state.currentPath); - } + 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. + const host = unmountEditorInline(); + _invalidateActiveTabCache(vault, path); state.editorVault = null; state.editorPath = null; - // Refresh file content if we're viewing the edited file - if (vault && path && state.currentVault === vault && state.currentPath === path) { + if (host) { + const rereadVault = state.currentVault || vault; + const rereadPath = state.currentPath || path; + if (rereadVault && rereadPath) openFile(rereadVault, rereadPath); + } else if (vault && path && state.currentVault === vault && state.currentPath === path) { + // Refresh file content if we're viewing the edited file + openFile(vault, path); + } +} + +/** + * Drop the cached render of a file in the active tab/panel so that activating + * it again re-fetches it from disk instead of restoring the pre-edit HTML. + */ +function _invalidateActiveTabCache(vault, path) { + if (!vault || !path) return; + try { + const split = window.PaneManager && typeof window.PaneManager.isSplit === 'function' && window.PaneManager.isSplit(); + const tm = split && typeof window.getActiveTabManager === 'function' + ? window.getActiveTabManager() + : window.TabManager; + if (!tm || !tm._tabCache) return; + const tab = (tm._tabs || []).find((tb) => tb.vault === vault && tb.path === path); + const key = tab ? tab.id : `${vault}::${path}`; + if (tm._tabCache[key]) tm._tabCache[key].data = null; + } catch (err) { + /* best effort — a stale cache only means a stale re-render */ + } +} + +/** + * Reload the displayed document after an external write (AI assistant tool + * `edit_file` / `append_to_file` / `create_file`, #93). + * + * Without it the outcome is wrong in two ways: the read view keeps showing the + * 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) { + 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' }, '*'); + return; + } + if (!state.editorView) return; + try { + const rawUrl = `/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`; + const rawData = await api(rawUrl); + const view = state.editorView; + if (!view || !view.state) return; + const raw = rawData && rawData.raw != null ? rawData.raw : ""; + if (view.state.doc.toString() === raw) return; + clearTimeout(window._obsigateAutoSaveTimer); + suppressAutoSaveOnce = true; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: raw }, + selection: { anchor: Math.min(view.state.selection.main.anchor, raw.length) }, + }); + var sd = document.getElementById("editor-save-dot"); + var sl = document.getElementById("editor-save-label"); + if (sd) sd.className = "editor-save-dot ok"; + if (sl) sl.textContent = "Saved"; + } catch (err) { + console.warn("Inline editor: reload after external write failed", err); + } + return; + } + // Not editing: refresh the read view when it shows the written document. + if (state.currentVault === vault && state.currentPath === path) { + _invalidateActiveTabCache(vault, path); openFile(vault, path); } } @@ -685,6 +837,14 @@ function initEditor() { }, { passive: false }, ); + + // #93 — The AI assistant wrote to a vault file: reload the displayed + // document (inline editor, Forge iframe or read view) so the change is + // 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); + }); } /** Copy text to clipboard — works on both HTTP and HTTPS */ @@ -713,4 +873,19 @@ function copyToClipboard(text) { } } -export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor, copyToClipboard }; +export { + getFileIcon, + safeCreateIcons, + flushIcons, + safeHighlight, + escapeHtml, + EXT_ICONS, + openEditor, + closeEditor, + reloadExternalWrite, + saveFile, + deleteFile, + waitForCodeMirror, + initEditor, + copyToClipboard, +}; diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index b3966e7..69732d2 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1,7 +1,8 @@ /* ObsiGate — Viewer module */ import { api, AuthManager } from './auth.js'; import { state } from './state.js'; -import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard } from './utils.js'; +import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js'; +import { isInlineEditorActive } 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'; @@ -518,6 +519,11 @@ function applyPrettyHighlight(codeEl, lang, text) { } export function renderFile(data) { + // #93 — An inline edition session (#editor-container mounted in the content + // area) is destroyed by this very re-render: release it first so the editor + // state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly + // instead of being wiped mid-session by a tab switch / sidebar click. + if (isInlineEditorActive()) detachInlineEditor(); const area = getContentArea(); // Handle PDF files — render in iframe with TOC sidebar @@ -749,11 +755,19 @@ export function renderFile(data) { const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]); forgeBtn.addEventListener("click", () => { var modal = document.getElementById("editor-modal"); - var container = modal.querySelector(".editor-container"); var bodyEl = document.getElementById("editor-body"); // Set editor state so parent buttons work state.editorVault = data.vault; state.editorPath = data.path; + // #93 — Forge replaces the document read view when this file is the one + // currently displayed; otherwise it falls back to the overlay. + var inline = activateInlineEditor(data.vault, data.path); + if (!inline) { + // A stale inline session (another document) must release the area first. + detachInlineEditor(); + state.editorVault = data.vault; + state.editorPath = data.path; + } // Update header: show "Forge" brand + filename var brand = modal.querySelector(".editor-brand"); var titleInp = document.getElementById("editor-title-input"); @@ -766,7 +780,8 @@ export function renderFile(data) { var iframe = document.createElement("iframe"); iframe.id = "forge-iframe"; iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now(); - iframe.style.cssText = "width:100%;height:82vh;border:none;display:block;"; + // Inline: the iframe fills the content area; overlay: the original 82vh. + iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;"; bodyEl.appendChild(iframe); modal.classList.add("active"); safeCreateIcons(); diff --git a/frontend/style.css b/frontend/style.css index 87b0297..a7d43a1 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -2795,6 +2795,47 @@ select { overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); } + +/* --- Inline edition (#93) -------------------------------------------------- + When a document is being edited, `#editor-container` is moved into the + document content area: the editor *replaces* the read view instead of + covering it with the overlay, so the AI assistant sidebar stays usable. + The overlay itself is neutralised but kept mounted (and `active`) because + the mobile editing ribbon (`#me-toolbar`, `.me-ribbon`) is anchored to it. */ +.editor-modal.editor-inline-mode { + display: block; + background: transparent; + padding: 0; + pointer-events: none; +} +.editor-modal.editor-inline-mode .me-ribbon { + pointer-events: auto; +} +.content-area.editor-inline-host, +.pane-content.editor-inline-host { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} +.content-area.editor-inline-host .editor-container.editor-inline, +.pane-content.editor-inline-host .editor-container.editor-inline { + flex: 1; + height: auto; + width: 100%; + min-height: 0; + max-width: none; + max-height: none; + border: none; + border-radius: 0; + box-shadow: none; + background: var(--bg-primary); +} +.editor-container.editor-inline .editor-body, +.editor-container.editor-inline .editor-body-cm { + flex: 1; + min-height: 0; +} .editor-header { display: flex; align-items: center; diff --git a/tests/frontend/editor-inline.test.mjs b/tests/frontend/editor-inline.test.mjs new file mode 100644 index 0000000..50f3086 --- /dev/null +++ b/tests/frontend/editor-inline.test.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +/** + * ObsiGate — Inline edition tests (ROADMAP #93). + * + * « Editer » and « Forge » must replace the document read view instead of + * opening an overlay on top of it (`#editor-container` is moved into the + * content area, so the AI assistant sidebar stays usable while editing). + * + * Covers: + * - frontend/js/editor-inline.js — DOM behaviour (mount/unmount/classes) + * - the wiring of that module in viewer.js / utils.js / sync.js / bookslm.js + * (static checks, like tests/frontend/toolbar-order.test.mjs) + * - the Forge iframe reload hook (frontend/editor-poc.html) + * - the CSS that makes the editor fill the content area (frontend/style.css) + * + * Usage: node tests/frontend/editor-inline.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { JSDOM } from "jsdom"; +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 dom = new JSDOM( + ` +
+
+
+
Accueil
+

Lecture

+
+
+
+
+
+
ObsiGate
+
+ +
+
+ `, + { url: "https://example.com/", pretendToBeVisual: true }, +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.HTMLElement = w.HTMLElement; +globalThis.Element = w.Element; +globalThis.Node = w.Node; + +const mod = await import( + pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "editor-inline.js")).href +); +const { + INLINE_CONTAINER_CLASS, + INLINE_HOST_CLASS, + INLINE_MODAL_CLASS, + getEditorModal, + getEditorContainer, + getInlineHostArea, + isInlineEditorActive, + mountEditorInline, + unmountEditorInline, +} = 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; + } +} + +const modal = () => document.getElementById("editor-modal"); +const container = () => document.getElementById("editor-container"); +const area = () => document.getElementById("content-area"); + +function reset() { + const c = container(); + const m = modal(); + if (c.parentElement !== m) m.appendChild(c); + c.classList.remove(INLINE_CONTAINER_CLASS); + m.classList.remove(INLINE_MODAL_CLASS); + area().classList.remove(INLINE_HOST_CLASS); + area().innerHTML = '

Lecture

'; + delete w._activePaneContentArea; + delete w.PaneManager; + delete w.getActiveContentArea; +} + +console.log("Inline edition (#93) — editor-inline.js"); + +test("lookup helpers resolve the container and the host area", () => { + reset(); + assert.equal(getEditorModal(), modal()); + assert.equal(getEditorContainer(), container()); + assert.equal(getInlineHostArea(), area()); + assert.equal(isInlineEditorActive(), false); +}); + +test("mount moves the container into the content area and drops the read view", () => { + reset(); + assert.equal(mountEditorInline(area()), true); + assert.equal(container().parentElement, area()); + assert.equal(area().querySelector(".md-content"), null, "read view replaced"); + assert.ok(container().classList.contains(INLINE_CONTAINER_CLASS)); + assert.ok(area().classList.contains(INLINE_HOST_CLASS)); + assert.ok(modal().classList.contains(INLINE_MODAL_CLASS)); + assert.equal(isInlineEditorActive(), true); +}); + +test("mount keeps the overlay mounted so the mobile ribbon keeps working", () => { + reset(); + mountEditorInline(area()); + assert.ok(modal(), "overlay still in the document"); + assert.equal(container().closest("#editor-modal"), null, "container left the overlay"); +}); + +test("mount is idempotent on the same host area", () => { + reset(); + mountEditorInline(area()); + assert.equal(mountEditorInline(area()), true); + assert.equal(area().querySelectorAll(".editor-container").length, 1); +}); + +test("mount refuses a missing area", () => { + reset(); + assert.equal(mountEditorInline(null), false); + assert.equal(isInlineEditorActive(), false); +}); + +test("unmount returns the host area, restores the overlay and clears classes", () => { + reset(); + mountEditorInline(area()); + const host = unmountEditorInline(); + assert.equal(host, area()); + assert.equal(container().parentElement, modal()); + assert.equal(container().classList.contains(INLINE_CONTAINER_CLASS), false); + assert.equal(area().classList.contains(INLINE_HOST_CLASS), false); + assert.equal(modal().classList.contains(INLINE_MODAL_CLASS), false); + assert.equal(isInlineEditorActive(), false); + assert.equal(area().children.length, 0, "host area left empty for the read view re-render"); +}); + +test("unmount without an inline session is a no-op", () => { + reset(); + assert.equal(unmountEditorInline(), null); + assert.equal(container().parentElement, modal()); +}); + +test("host area follows the active pane in split view", () => { + reset(); + const paneArea = document.createElement("div"); + paneArea.className = "content-area pane-content"; + document.body.appendChild(paneArea); + w.PaneManager = { isSplit: () => true }; + w.getActiveContentArea = () => paneArea; + assert.equal(getInlineHostArea(), paneArea); + // A transient render override (pane-manager _renderFileInPane) wins. + w._activePaneContentArea = area(); + assert.equal(getInlineHostArea(), area()); + assert.equal(mountEditorInline(paneArea), true); + assert.equal(container().parentElement, paneArea); + unmountEditorInline(); + paneArea.remove(); + reset(); +}); + +// ── Static wiring checks ─────────────────────────────────────────────────── + +const read = (...parts) => readFileSync(path.join(REPO_ROOT, ...parts), "utf8"); + +const viewerSrc = read("frontend", "js", "viewer.js"); +const utilsSrc = read("frontend", "js", "utils.js"); +const syncSrc = read("frontend", "js", "sync.js"); +const bookslmSrc = read("frontend", "js", "bookslm.js"); +const indexSrc = read("frontend", "index.html"); +const cssSrc = read("frontend", "style.css"); +const forgeSrc = read("frontend", "editor-poc.html"); + +console.log("Inline edition (#93) — wiring"); + +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';/); + 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\(\);/); +}); + +test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => { + assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/); + assert.match( + viewerSrc, + /iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/, + ); +}); + +test("utils.js activateInlineEditor only takes over the displayed document", () => { + const fn = utilsSrc.match(/export function activateInlineEditor\(([\s\S]*?)\n\}/); + assert.ok(fn, "activateInlineEditor not found"); + assert.match(fn[1], /state\.currentVault !== vaultName \|\| state\.currentPath !== filePath/); + assert.match(fn[1], /mountEditorInline\(area\)/); +}); + +test("utils.js openEditor mounts inline, closeEditor releases the host area", () => { + assert.match(utilsSrc, /activateInlineEditor\(vaultName, filePath\);/); + const close = utilsSrc.match(/function closeEditor\(\) \{([\s\S]*?)\n\}/); + assert.ok(close, "closeEditor not found"); + assert.match(close[1], /const host = unmountEditorInline\(\);/); + assert.match(close[1], /if \(host\) \{/); +}); + +test("utils.js detachInlineEditor tears the session down without re-rendering", () => { + const fn = utilsSrc.match(/export function detachInlineEditor\(\) \{([\s\S]*?)\n\}/); + assert.ok(fn, "detachInlineEditor not found"); + assert.match(fn[1], /if \(!isInlineEditorActive\(\)\) return;/); + assert.match(fn[1], /state\.editorView\.destroy\(\)/); + assert.match(fn[1], /unmountEditorInline\(\)/); +}); + +test("utils.js reloads the displayed document after an AI write", () => { + const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path\) \{([\s\S]*?)\n\}/); + assert.ok(fn, "reloadExternalWrite not found"); + assert.match(fn[1], /postMessage\(\{ type: 'parent-reload' \}, '\*'\)/); + 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("sync.js forge-close goes through the shared close path", () => { + assert.match(syncSrc, /import \{ closeEditor \} from '\.\/utils\.js';/); + 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("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;/); + assert.match(bookslmSrc, /this\._notifyFileWritten\(data\);/); + const notify = bookslmSrc.match(/_notifyFileWritten\(data\) \{([\s\S]*?)\n \}/); + assert.ok(notify, "_notifyFileWritten not found"); + for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup"]) { + assert.ok(notify[1].includes(`'${tool}'`), `${tool} missing from the write tools`); + } + assert.match(notify[1], /new CustomEvent\('obsigate:file-written'/); +}); + +test("editor-poc.html reloads Forge buffer on parent-reload", () => { + assert.match(forgeSrc, /if \(e\.data\.type === 'parent-reload'\) \{/); + const handler = forgeSrc.match(/if \(e\.data\.type === 'parent-reload'\) \{([\s\S]*?)\n \}/); + assert.ok(handler, "parent-reload handler not found"); + assert.match(handler[1], /clearTimeout\(saveTimer\);/); + assert.match(handler[1], /loadFile\(\);/); +}); + +test("style.css lets the inline editor fill the content area", () => { + assert.match(cssSrc, /\.editor-modal\.editor-inline-mode \{/); + assert.match(cssSrc, /\.editor-modal\.editor-inline-mode \{[\s\S]*?pointer-events: none;/); + assert.match(cssSrc, /\.content-area\.editor-inline-host,[\s\S]*?display: flex;/); + const fill = cssSrc.match(/\.content-area\.editor-inline-host \.editor-container\.editor-inline,[\s\S]*?\{([\s\S]*?)\n\}/); + assert.ok(fill, "inline container rule not found"); + assert.match(fill[1], /flex: 1;/); + assert.match(fill[1], /height: auto;/, "must win over the mobile 100vh rule"); + assert.match(fill[1], /max-width: none;/); +}); + +console.log(`\n${testCount - failCount}/${testCount} tests passed`); +if (failCount > 0) { + console.error(`${failCount} test(s) failed`); + process.exit(1); +} diff --git a/tests/frontend/unit.test.mjs b/tests/frontend/unit.test.mjs index 6a8a701..55e3f96 100644 --- a/tests/frontend/unit.test.mjs +++ b/tests/frontend/unit.test.mjs @@ -107,6 +107,7 @@ function testModulesHaveImports() { { file: 'i18n.js', reason: 'standalone — globals' }, { file: 'offline-db.js', reason: 'standalone — IndexedDB' }, { file: 'pane-manager.js', reason: 'standalone — no deps' }, + { file: 'editor-inline.js', reason: 'standalone — DOM-only helpers for the inline editor host (#93)' }, { file: 'themes.js', reason: 'standalone — no deps' }, { file: 'sidebar_raw.js', reason: 'legacy file — excluded' }, { file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' }, diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index c3822b4..9c63abf 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -666,6 +666,45 @@ class TestGeneralPrompt: assert "notes/recent.md" in prompt assert "auth" in prompt + def test_general_prompt_includes_edited_document(self): + """#93 — the live edition session is announced to the assistant.""" + from backend.bookslm import build_general_system_prompt + + prompt = build_general_system_prompt( + ["Alpha"], + app_context={ + "current_path": "notes/a.md", + "editing": {"vault": "Alpha", "path": "notes/a.md", "surface": "editor"}, + }, + ) + + assert "Document en cours d'édition" in prompt + assert "notes/a.md" in prompt + assert "edit_file" in prompt + + def test_general_prompt_marks_forge_surface(self): + """#93 — Forge (iframe) is distinguished from the CodeMirror editor.""" + from backend.bookslm import build_general_system_prompt + + prompt = build_general_system_prompt( + ["Alpha"], + app_context={"editing": {"vault": "Alpha", "path": "notes/a.md", "surface": "forge"}}, + ) + + assert "dans Forge" in prompt + assert "Document en cours d'édition" in prompt + + def test_general_prompt_ignores_malformed_editing(self): + """#93 — a malformed `editing` payload must not break the prompt.""" + from backend.bookslm import build_general_system_prompt + + prompt = build_general_system_prompt( + ["Alpha"], + app_context={"editing": {"vault": "Alpha"}}, + ) + + assert "Document en cours d'édition" not in prompt + def test_general_prompt_without_app_context_is_unchanged(self): """No context → no app-context block (backwards compatible).""" from backend.bookslm import build_general_system_prompt