feat(editeur): #93 edition inline — Editer/Forge remplacent la vue lecture
CI / lint (push) Successful in 1m26s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m17s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m47s

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.
This commit is contained in:
2026-09-15 23:40:10 -04:00
parent f630771150
commit e3df4bbf00
16 changed files with 920 additions and 33 deletions
+3 -1
View File
@@ -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 ─────────────────────────────────────────────────────────
+22
View File
@@ -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
+11
View File
@@ -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")
+3 -2
View File
@@ -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 |
+125
View File
@@ -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.
+10
View File
@@ -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;
+11 -3
View File
@@ -1211,7 +1211,13 @@
apercu rapide. Cliquez sur
<strong>Forge</strong> dans la
barre d'outils pour l'editeur
avance.</span
avance. <strong>Editer</strong> et
<strong>Forge</strong> remplacent
le document en mode lecture
(retour par ✓, ✕ ou
<strong>Echap</strong>) : le
panneau de l'assistant IA reste
accessible a cote.</span
>
</div>
</div>
@@ -1370,9 +1376,11 @@
</div>
</div>
<!-- Editor Modal -->
<!-- Editor Modal — the .editor-container is moved into the document
content area (inline mode, #93) when a file is being edited, so the
editor replaces the read view instead of overlaying it. -->
<div class="editor-modal" id="editor-modal">
<div class="editor-container">
<div class="editor-container" id="editor-container">
<div class="editor-header">
<span class="editor-brand" data-i18n="header.logo">ObsiGate</span>
<input
+45
View File
@@ -781,6 +781,12 @@ class BooksLM {
ctx.open_documents = docs.map((d) => ({ 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;
}
+115
View File
@@ -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;
}
+4 -16
View File
@@ -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();
}
});
}
+183 -8
View File
@@ -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,
};
+18 -3
View File
@@ -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();
+41
View File
@@ -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;
+289
View File
@@ -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(
`<!DOCTYPE html><html><body>
<div class="app-container">
<div class="content-wrapper">
<div class="content-area" id="content-area">
<div class="file-header"><div class="file-title">Accueil</div></div>
<div class="md-content" id="file-rendered-content"><p>Lecture</p></div>
</div>
</div>
</div>
<div class="editor-modal" id="editor-modal">
<div class="editor-container" id="editor-container">
<div class="editor-header"><span class="editor-brand">ObsiGate</span></div>
<div class="editor-body editor-body-cm" id="editor-body"></div>
<div class="editor-footer"></div>
</div>
</div>
</body></html>`,
{ 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 = '<div class="md-content" id="file-rendered-content"><p>Lecture</p></div>';
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, /<div class="editor-container" id="editor-container">/);
});
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);
}
+1
View File
@@ -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' },
+39
View File
@@ -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