feat(editeur): #93 edition inline — Editer/Forge remplacent la vue lecture
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:
@@ -38,7 +38,7 @@ jobs:
|
|||||||
- name: Frontend unit tests
|
- name: Frontend unit tests
|
||||||
run: node tests/frontend/unit.test.mjs
|
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: |
|
run: |
|
||||||
cd tests/frontend
|
cd tests/frontend
|
||||||
if [ -d node_modules ]; then
|
if [ -d node_modules ]; then
|
||||||
@@ -52,6 +52,7 @@ jobs:
|
|||||||
node semantic-search.test.mjs
|
node semantic-search.test.mjs
|
||||||
node desktop.test.mjs
|
node desktop.test.mjs
|
||||||
node toolbar-order.test.mjs
|
node toolbar-order.test.mjs
|
||||||
|
node editor-inline.test.mjs
|
||||||
else
|
else
|
||||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||||
npm install --no-audit --no-fund --silent
|
npm install --no-audit --no-fund --silent
|
||||||
@@ -65,6 +66,7 @@ jobs:
|
|||||||
node semantic-search.test.mjs
|
node semantic-search.test.mjs
|
||||||
node desktop.test.mjs
|
node desktop.test.mjs
|
||||||
node toolbar-order.test.mjs
|
node toolbar-order.test.mjs
|
||||||
|
node editor-inline.test.mjs
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Tests ─────────────────────────────────────────────────────────
|
# ── Tests ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 : `tests/frontend/ai.test.mjs` (82), `tests/test_bookslm.py::TestToolEventSources` (4),
|
||||||
`tests/test_web_tools.py` (10).
|
`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é
|
### Corrigé
|
||||||
|
|
||||||
- **#91 — Bulle utilisateur trop étroite & ancre d'envoi retardée** : le plafond de
|
- **#91 — Bulle utilisateur trop étroite & ancre d'envoi retardée** : le plafond de
|
||||||
|
|||||||
@@ -529,6 +529,17 @@ def _format_app_context(app_context: dict[str, Any] | None, recent_files: list[d
|
|||||||
if rendered_docs:
|
if rendered_docs:
|
||||||
lines.append("- Documents ouverts dans les onglets/panneaux : " + ", ".join(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")
|
query = app_context.get("search_query")
|
||||||
if query:
|
if query:
|
||||||
total = app_context.get("search_total")
|
total = app_context.get("search_total")
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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) |
|
| 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) |
|
| 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) |
|
| 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é |
|
| 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 |
|
| 🔵 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 |
|
| ⚪ 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 |
|
| ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1450,6 +1450,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
window.addEventListener('message', function(e) {
|
window.addEventListener('message', function(e) {
|
||||||
if (!e.data || !e.data.type) return;
|
if (!e.data || !e.data.type) return;
|
||||||
if (e.data.type === 'parent-save') { forceSave(); }
|
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 (e.data.type === 'parent-delete') {
|
||||||
if (!fileVault || !filePath) return;
|
if (!fileVault || !filePath) return;
|
||||||
if (!confirm('Supprimer definitivement ' + filePath + ' ?')) return;
|
if (!confirm('Supprimer definitivement ' + filePath + ' ?')) return;
|
||||||
|
|||||||
+11
-3
@@ -1211,7 +1211,13 @@
|
|||||||
apercu rapide. Cliquez sur
|
apercu rapide. Cliquez sur
|
||||||
<strong>Forge</strong> dans la
|
<strong>Forge</strong> dans la
|
||||||
barre d'outils pour l'editeur
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1370,9 +1376,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-modal" id="editor-modal">
|
||||||
<div class="editor-container">
|
<div class="editor-container" id="editor-container">
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<span class="editor-brand" data-i18n="header.logo">ObsiGate</span>
|
<span class="editor-brand" data-i18n="header.logo">ObsiGate</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -781,6 +781,12 @@ class BooksLM {
|
|||||||
ctx.open_documents = docs.map((d) => ({ vault: d.vault, path: d.path }));
|
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;
|
const query = state.advancedSearchLastQuery;
|
||||||
if (query) {
|
if (query) {
|
||||||
ctx.search_query = query;
|
ctx.search_query = query;
|
||||||
@@ -803,6 +809,43 @@ class BooksLM {
|
|||||||
return ctx;
|
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 ───────────────────────────────────────────────────────
|
// ── Rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
_render() {
|
_render() {
|
||||||
@@ -2575,6 +2618,8 @@ class BooksLM {
|
|||||||
step: data.step || null,
|
step: data.step || null,
|
||||||
sources: Array.isArray(data.sources) ? data.sources : [],
|
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 }));
|
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -6,6 +6,7 @@ import { api, AuthManager } from './auth.js';
|
|||||||
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||||
import { loadRecentFiles, initAboutModal } from './config.js';
|
import { loadRecentFiles, initAboutModal } from './config.js';
|
||||||
import { openFile } from './viewer.js';
|
import { openFile } from './viewer.js';
|
||||||
|
import { closeEditor } from './utils.js';
|
||||||
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
||||||
import { initThemes } from './themes.js';
|
import { initThemes } from './themes.js';
|
||||||
|
|
||||||
@@ -636,22 +637,9 @@ export function init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.data.type === 'forge-close') {
|
if (e.data.type === 'forge-close') {
|
||||||
var modal = document.getElementById('editor-modal');
|
// #93 — shared close path: hands the editor container back to the modal
|
||||||
var forgeFrame = document.getElementById('forge-iframe');
|
// (inline mode) and re-renders the document read view.
|
||||||
if (forgeFrame) forgeFrame.remove();
|
closeEditor();
|
||||||
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); });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-8
@@ -4,6 +4,67 @@ import { openFile, showWelcome } from './viewer.js';
|
|||||||
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
|
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
|
||||||
import { createAIToolbar } from './ai.js';
|
import { createAIToolbar } from './ai.js';
|
||||||
import { startCollab, stopCollab } from './collab.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
|
// File extension → Lucide icon mapping
|
||||||
@@ -318,6 +379,11 @@ async function openEditor(vaultName, filePath) {
|
|||||||
if (footerEl) footerEl.style.display = "";
|
if (footerEl) footerEl.style.display = "";
|
||||||
if (container) container.style.maxHeight = "";
|
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();
|
filenameInput.value = filePath.split("/").pop();
|
||||||
statFile.textContent = vaultName + "/" + filePath;
|
statFile.textContent = vaultName + "/" + filePath;
|
||||||
|
|
||||||
@@ -361,6 +427,12 @@ async function openEditor(vaultName, filePath) {
|
|||||||
// Auto-save: debounced save after 2s of inactivity
|
// Auto-save: debounced save after 2s of inactivity
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
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);
|
clearTimeout(window._obsigateAutoSaveTimer);
|
||||||
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
|
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
|
||||||
// Mark dirty
|
// Mark dirty
|
||||||
@@ -506,26 +578,106 @@ function closeEditor() {
|
|||||||
const vault = state.editorVault;
|
const vault = state.editorVault;
|
||||||
const path = state.editorPath;
|
const path = state.editorPath;
|
||||||
const modal = document.getElementById("editor-modal");
|
const modal = document.getElementById("editor-modal");
|
||||||
|
if (!modal) return;
|
||||||
modal.classList.remove("active");
|
modal.classList.remove("active");
|
||||||
// Restore brand text
|
// Restore brand text
|
||||||
var brand = modal.querySelector('.editor-brand');
|
var brand = modal.querySelector('.editor-brand');
|
||||||
if (brand) brand.textContent = 'ObsiGate';
|
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();
|
stopCollab();
|
||||||
if (state.editorView) {
|
if (state.editorView) {
|
||||||
state.editorView.destroy();
|
state.editorView.destroy();
|
||||||
state.editorView = null;
|
state.editorView = null;
|
||||||
}
|
}
|
||||||
state.fallbackEditorEl = 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");
|
var forgeFrame = document.getElementById("forge-iframe");
|
||||||
if (forgeFrame) {
|
if (forgeFrame) forgeFrame.remove();
|
||||||
forgeFrame.remove();
|
// #93 — give the editor container back to the modal; an inline session also
|
||||||
if (state.currentVault && state.currentPath) openFile(state.currentVault, state.currentPath);
|
// releases the content area, whose read view must be re-rendered.
|
||||||
}
|
const host = unmountEditorInline();
|
||||||
|
_invalidateActiveTabCache(vault, path);
|
||||||
state.editorVault = null;
|
state.editorVault = null;
|
||||||
state.editorPath = null;
|
state.editorPath = null;
|
||||||
// Refresh file content if we're viewing the edited file
|
if (host) {
|
||||||
if (vault && path && state.currentVault === vault && state.currentPath === path) {
|
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);
|
openFile(vault, path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,6 +837,14 @@ function initEditor() {
|
|||||||
},
|
},
|
||||||
{ passive: false },
|
{ 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 */
|
/** 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
@@ -1,7 +1,8 @@
|
|||||||
/* ObsiGate — Viewer module */
|
/* ObsiGate — Viewer module */
|
||||||
import { api, AuthManager } from './auth.js';
|
import { api, AuthManager } from './auth.js';
|
||||||
import { state } from './state.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 { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
|
||||||
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
|
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
|
||||||
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
||||||
@@ -518,6 +519,11 @@ function applyPrettyHighlight(codeEl, lang, text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderFile(data) {
|
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();
|
const area = getContentArea();
|
||||||
|
|
||||||
// Handle PDF files — render in iframe with TOC sidebar
|
// 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")]);
|
const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]);
|
||||||
forgeBtn.addEventListener("click", () => {
|
forgeBtn.addEventListener("click", () => {
|
||||||
var modal = document.getElementById("editor-modal");
|
var modal = document.getElementById("editor-modal");
|
||||||
var container = modal.querySelector(".editor-container");
|
|
||||||
var bodyEl = document.getElementById("editor-body");
|
var bodyEl = document.getElementById("editor-body");
|
||||||
// Set editor state so parent buttons work
|
// Set editor state so parent buttons work
|
||||||
state.editorVault = data.vault;
|
state.editorVault = data.vault;
|
||||||
state.editorPath = data.path;
|
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
|
// Update header: show "Forge" brand + filename
|
||||||
var brand = modal.querySelector(".editor-brand");
|
var brand = modal.querySelector(".editor-brand");
|
||||||
var titleInp = document.getElementById("editor-title-input");
|
var titleInp = document.getElementById("editor-title-input");
|
||||||
@@ -766,7 +780,8 @@ export function renderFile(data) {
|
|||||||
var iframe = document.createElement("iframe");
|
var iframe = document.createElement("iframe");
|
||||||
iframe.id = "forge-iframe";
|
iframe.id = "forge-iframe";
|
||||||
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
|
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);
|
bodyEl.appendChild(iframe);
|
||||||
modal.classList.add("active");
|
modal.classList.add("active");
|
||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
|
|||||||
@@ -2795,6 +2795,47 @@ select {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
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 {
|
.editor-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ function testModulesHaveImports() {
|
|||||||
{ file: 'i18n.js', reason: 'standalone — globals' },
|
{ file: 'i18n.js', reason: 'standalone — globals' },
|
||||||
{ file: 'offline-db.js', reason: 'standalone — IndexedDB' },
|
{ file: 'offline-db.js', reason: 'standalone — IndexedDB' },
|
||||||
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
|
{ 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: 'themes.js', reason: 'standalone — no deps' },
|
||||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||||
|
|||||||
@@ -666,6 +666,45 @@ class TestGeneralPrompt:
|
|||||||
assert "notes/recent.md" in prompt
|
assert "notes/recent.md" in prompt
|
||||||
assert "auth" 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):
|
def test_general_prompt_without_app_context_is_unchanged(self):
|
||||||
"""No context → no app-context block (backwards compatible)."""
|
"""No context → no app-context block (backwards compatible)."""
|
||||||
from backend.bookslm import build_general_system_prompt
|
from backend.bookslm import build_general_system_prompt
|
||||||
|
|||||||
Reference in New Issue
Block a user