fix(editeur): #93 garde-fous de session d'edition inline (onglets, ecriture externe) + hooks de versionnage documentes
CI / lint (push) Successful in 1m27s
CI / security (push) Successful in 57s
CI / test (push) Successful in 2m52s
CI / build (push) Successful in 53s
CI / e2e (push) Successful in 10m45s

Une session d'edition inline (Editer/Forge) qui possede la zone de contenu etait detruite par deux chemins qui vident cette zone : activation d'onglet (TabManager.activate, PaneTabManager.activate) -> detachInlineEditor() avant le placeholder ; evenement SSE index_updated -> reloadExternalWrite() recharge le tampon de l'editeur au lieu de re-rendre la vue lecture. Nouveau helper queryEditor() ; closeEditor() remet le conteneur dans la modale avant de restaurer en-tete/pied/marque. docs/CONTRIBUTING.md documente scripts/install-hooks.sh.
This commit is contained in:
2026-09-16 11:43:41 -04:00
parent 168261964e
commit 788d84a2bf
19 changed files with 155 additions and 38 deletions
+61 -3
View File
@@ -67,6 +67,7 @@ const {
isInlineEditorActive,
mountEditorInline,
unmountEditorInline,
queryEditor,
} = mod;
let testCount = 0;
@@ -161,6 +162,17 @@ test("unmount without an inline session is a no-op", () => {
assert.equal(container().parentElement, modal());
});
test("queryEditor follows the container (modal → content area)", () => {
reset();
assert.equal(queryEditor(".editor-brand").textContent, "ObsiGate");
mountEditorInline(area());
assert.ok(queryEditor(".editor-brand"), "brand reachable while mounted inline");
assert.equal(document.getElementById("editor-modal").querySelector(".editor-brand"), null, "brand really left the overlay");
unmountEditorInline();
assert.ok(document.getElementById("editor-modal").querySelector(".editor-brand"), "brand back in the overlay");
reset();
});
test("host area follows the active pane in split view", () => {
reset();
const paneArea = document.createElement("div");
@@ -193,12 +205,37 @@ const forgeSrc = read("frontend", "editor-poc.html");
console.log("Inline edition (#93) — wiring");
test("utils.js / viewer.js import every editor-inline helper they call", () => {
const helpers = ["getEditorContainer", "getInlineHostArea", "isInlineEditorActive", "mountEditorInline", "unmountEditorInline", "queryEditor"];
const strip = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
for (const [name, raw] of [["utils.js", utilsSrc], ["viewer.js", viewerSrc]]) {
const src = strip(raw);
const marker = "from './editor-inline.js';";
const end = raw.indexOf(marker);
const start = end === -1 ? -1 : raw.lastIndexOf("import {", end);
const imported =
end === -1 || start === -1
? []
: raw
.slice(start + "import {".length, end)
.replace(/\}/g, "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const helper of helpers) {
if (new RegExp(`\\b${helper}\\(`).test(src)) {
assert.ok(imported.includes(helper), `${name} calls ${helper}() without importing it`);
}
}
}
});
test("index.html gives the editor container a stable id", () => {
assert.match(indexSrc, /<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';/);
assert.ok(viewerSrc.includes("import { isInlineEditorActive, queryEditor } from './editor-inline.js';"));
const guard = viewerSrc.match(/export function renderFile\(data\) \{([\s\S]*?)\n const area = getContentArea\(\);/);
assert.ok(guard, "renderFile head not found");
assert.match(guard[1], /if \(isInlineEditorActive\(\)\) detachInlineEditor\(\);/);
@@ -206,6 +243,8 @@ test("viewer.js mounts the editor inline when rendering is taken over", () => {
test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => {
assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/);
assert.ok(viewerSrc.includes('var brand = queryEditor(".editor-brand");'), "header query must follow the container");
assert.equal(viewerSrc.includes('modal.querySelector(".editor-brand")'), false, "modal-scoped brand query would break inline mode");
assert.match(
viewerSrc,
/iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/,
@@ -245,12 +284,32 @@ test("utils.js reloads the displayed document after an AI write", () => {
});
test("sync.js forge-close goes through the shared close path", () => {
assert.match(syncSrc, /import \{ closeEditor \} from '\.\/utils\.js';/);
assert.ok(syncSrc.includes("import { closeEditor, reloadExternalWrite } from './utils.js';"), "utils import missing");
const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/);
assert.ok(handler, "forge-close handler not found");
assert.match(handler[1], /closeEditor\(\);/);
});
test("sync.js keeps an open edition session alive on external file changes", () => {
const sse = syncSrc.match(/const changed = \(data\.changes \|\| \[\]\)([\s\S]*?)\n \}/);
assert.ok(sse, "SSE index_updated refresh block not found");
assert.ok(sse[1].includes("reloadExternalWrite(state.currentVault, state.currentPath);"), "SSE refresh must not re-render the read view");
assert.ok(!/^\s*openFile\(state\.currentVault/m.test(sse[1]), "openFile must not be called directly here");
});
test("tab activation releases an inline edition session first", () => {
const uiActivate = read("frontend", "js", "ui.js").match(/async activate\(tabId\) \{([\s\S]*?)\n \},/);
assert.ok(uiActivate, "TabManager.activate not found");
assert.match(uiActivate[1], /detachInlineEditor\(\);/);
});
test("pane activation releases an inline edition session first", () => {
const paneSrc = read("frontend", "js", "pane-manager.js");
const paneActivate = paneSrc.match(/async activate\(tabId\) \{([\s\S]*?)\n const cache/);
assert.ok(paneActivate, "PaneTabManager.activate not found");
assert.match(paneActivate[1], /detachInlineEditor\(\);/);
});
test("bookslm.js reports the edited document and the assistant writes", () => {
assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/);
assert.match(bookslmSrc, /if \(editing\) ctx\.editing = editing;/);
@@ -281,7 +340,6 @@ test("style.css lets the inline editor fill the content area", () => {
assert.match(fill[1], /height: auto;/, "must win over the mobile 100vh rule");
assert.match(fill[1], /max-width: none;/);
});
// ── BUG-045: one scroll surface only in the CodeMirror editor ──
test("style.css: #editor-body scrolls through the CodeMirror scroller only", () => {
const body = cssSrc.match(/\.editor-body\.editor-body-cm \{([^}]*)\}/);