feat(viewer): mode pretty print avec coloration syntaxique et numeros de ligne pour fichiers texte (#89)
CI / lint (push) Successful in 1m17s
CI / security (push) Successful in 56s
CI / test (push) Successful in 2m25s
CI / build (push) Successful in 48s
CI / e2e (push) Successful in 10m35s

This commit is contained in:
2026-09-14 19:12:43 -04:00
parent 96bb1cbcae
commit d3f7a04411
6 changed files with 170 additions and 0 deletions
+3
View File
@@ -20,6 +20,9 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
overlay plein écran à double cible (dépôt vault vs analyse IA), surbrillance des cibles dans overlay plein écran à double cible (dépôt vault vs analyse IA), surbrillance des cibles dans
l'arborescence, et dropzone BooksLM pour injection de contexte ou pièces jointes. l'arborescence, et dropzone BooksLM pour injection de contexte ou pièces jointes.
Fiche : [docs/features/drag-and-drop-ai.md](./docs/features/drag-and-drop-ai.md). Fiche : [docs/features/drag-and-drop-ai.md](./docs/features/drag-and-drop-ai.md).
- **#89 Pretty print fichiers texte** — bouton « Pretty » dans le mode lecture des fichiers
non-markdown (code, JSON, configs, logs...) : formatage JSON, numéros de ligne avec gouttière
et coloration syntaxique highlight.js alignée sur le thème clair/sombre.
- **#88 Assistant IA : contexte applicatif** — l'assistant en mode **Général** - **#88 Assistant IA : contexte applicatif** — l'assistant en mode **Général**
reçoit l'état vivant de l'interface (`app_context`) et connaît désormais les reçoit l'état vivant de l'interface (`app_context`) et connaît désormais les
documents ouverts, le répertoire et le vault courants, la recherche en cours documents ouverts, le répertoire et le vault courants, la recherche en cours
+51
View File
@@ -446,6 +446,27 @@ async function renderBacklinksPanel(vault, path, container) {
} }
} }
// ── Pretty print (mode lecture enrichi pour fichiers texte non-markdown) ──
// Detecte le langage depuis le HTML deja rendu par le backend (classe
// language-xxx), formate le JSON, puis genere un bloc avec gouttiere de
// numeros de ligne. La coloration est appliquee ensuite via highlight.js.
function detectPrettyLang(data) {
if (data.is_json || data.extension === ".json") return "json";
const m = /class="language-([\w-]+)/.exec(data.html || "");
return m ? m[1] : "plaintext";
}
function buildPrettyHtml(data, raw) {
let text = raw;
const lang = detectPrettyLang(data);
if (lang === "json") {
try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* JSON invalide: on affiche brut */ }
}
const lines = text.split("\n");
const gutter = lines.map((_, i) => `<span>${i + 1}</span>`).join("");
return `<div class="pretty-view"><div class="pp-gutter" aria-hidden="true">${gutter}</div><pre class="pp-pre"><code class="language-${lang}">${escapeHtml(text)}</code></pre></div>`;
}
export function renderFile(data) { export function renderFile(data) {
const area = getContentArea(); const area = getContentArea();
@@ -774,10 +795,40 @@ export function renderFile(data) {
} }
}); });
// Pretty print — mode lecture enrichi (numeros de ligne + coloration)
// pour les fichiers texte non-markdown (code, JSON, configs, logs...)
let prettyBtn = null;
const isTextViewerFile = !data.is_markdown && !data.is_csv && !data.unsupported && !data.is_pdf && !data.is_image && !data.is_excalidraw;
if (isTextViewerFile) {
prettyBtn = el("button", { class: "btn-action", title: t("viewer.pretty_title") || "Mode formaté avec couleurs et numéros de ligne" }, [icon("sparkles", 14), document.createTextNode(t("viewer.pretty") || "Pretty")]);
prettyBtn.addEventListener("click", async () => {
const on = !prettyBtn.classList.contains("active");
if (on) {
try {
if (!state.cachedRawSource) {
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
const rawData = await api(rawUrl);
state.cachedRawSource = rawData.raw;
}
} catch (_) {
state.cachedRawSource = mdDiv.textContent || "";
}
mdDiv.innerHTML = buildPrettyHtml(data, state.cachedRawSource);
safeHighlight(mdDiv.querySelector("code"));
prettyBtn.classList.add("active");
} else {
mdDiv.innerHTML = data.html;
mdDiv.querySelectorAll("pre code").forEach((block) => safeHighlight(block));
prettyBtn.classList.remove("active");
}
});
}
const fileActions = [copyBtn, sourceBtn]; const fileActions = [copyBtn, sourceBtn];
if (!data.is_image) fileActions.push(mdBtn); if (!data.is_image) fileActions.push(mdBtn);
if (data.is_markdown) fileActions.push(pdfBtn, exportWrap, forgeBtn, tocBtn); if (data.is_markdown) fileActions.push(pdfBtn, exportWrap, forgeBtn, tocBtn);
if (!data.unsupported) fileActions.push(editBtn); if (!data.unsupported) fileActions.push(editBtn);
if (prettyBtn) fileActions.push(prettyBtn);
fileActions.push(openNewWindowBtn, shareBtn, bookmarkBtn); fileActions.push(openNewWindowBtn, shareBtn, bookmarkBtn);
// Assemble // Assemble
+2
View File
@@ -1687,6 +1687,8 @@
"viewer.download": "Download", "viewer.download": "Download",
"viewer.download_md": "Download as .md", "viewer.download_md": "Download as .md",
"viewer.download_file": "Download file", "viewer.download_file": "Download file",
"viewer.pretty": "Pretty",
"viewer.pretty_title": "Formatted mode: colors and line numbers",
"viewer.download_pdf": "Download as PDF", "viewer.download_pdf": "Download as PDF",
"viewer.edit": "Edit", "viewer.edit": "Edit",
"viewer.error": "Loading error", "viewer.error": "Loading error",
+2
View File
@@ -1687,6 +1687,8 @@
"viewer.download": "Télécharger", "viewer.download": "Télécharger",
"viewer.download_md": "Télécharger en .md", "viewer.download_md": "Télécharger en .md",
"viewer.download_file": "Télécharger le fichier", "viewer.download_file": "Télécharger le fichier",
"viewer.pretty": "Pretty",
"viewer.pretty_title": "Mode formaté : couleurs et numéros de ligne",
"viewer.download_pdf": "Télécharger en PDF", "viewer.download_pdf": "Télécharger en PDF",
"viewer.edit": "Éditer", "viewer.edit": "Éditer",
"viewer.error": "Erreur de chargement", "viewer.error": "Erreur de chargement",
+41
View File
@@ -2052,6 +2052,47 @@ select {
overflow-y: auto; overflow-y: auto;
} }
/* --- Pretty print (mode lecture fichiers texte non-markdown) --- */
.pretty-view {
display: flex;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
max-height: 78vh;
}
.pretty-view .pp-gutter {
flex: 0 0 auto;
display: flex;
flex-direction: column;
padding: 16px 10px 16px 14px;
text-align: right;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
line-height: 1.55;
color: var(--text-muted);
opacity: 0.65;
user-select: none;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow: hidden;
}
.pretty-view .pp-pre {
flex: 1 1 auto;
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
padding: 16px !important;
overflow: auto;
}
.pretty-view .pp-pre code {
font-family: "JetBrains Mono", monospace;
font-size: 0.85rem;
line-height: 1.55;
white-space: pre;
}
/* --- Markdown Rendered Content --- */ /* --- Markdown Rendered Content --- */
.md-content h1, .md-content h1,
.md-content h2, .md-content h2,
+71
View File
@@ -0,0 +1,71 @@
/**
* Frontend tests for Pretty print helpers (#89).
* detectPrettyLang / buildPrettyHtml are pure functions extracted from
* frontend/js/viewer.js; they are evaluated in a sandbox to avoid importing
* the full viewer module chain (browser-only dependencies).
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const src = readFileSync(path.join(__dirname, "..", "..", "frontend", "js", "viewer.js"), "utf8");
function extractFn(name) {
const start = src.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in viewer.js`);
let depth = 0;
let i = src.indexOf("{", start);
const begin = i;
for (; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth--;
if (depth === 0) break;
}
}
return src.slice(start, i + 1);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
const sandbox = { escapeHtml };
vm.createContext(sandbox);
vm.runInContext(`${extractFn("detectPrettyLang")}\n${extractFn("buildPrettyHtml")}\nglobalThis.detectPrettyLang = detectPrettyLang; globalThis.buildPrettyHtml = buildPrettyHtml;`, sandbox);
const { detectPrettyLang, buildPrettyHtml } = sandbox;
console.log("Testing detectPrettyLang...");
assert.equal(detectPrettyLang({ is_json: true }), "json");
assert.equal(detectPrettyLang({ extension: ".json" }), "json");
assert.equal(detectPrettyLang({ extension: ".py", html: '<pre><code class="language-python">x</code></pre>' }), "python");
assert.equal(detectPrettyLang({ extension: ".sh", html: '<pre><code class="language-bash">x</code></pre>' }), "bash");
assert.equal(detectPrettyLang({ extension: ".txt", html: "<pre><code>x</code></pre>" }), "plaintext");
console.log("Testing buildPrettyHtml (json reformat)...");
const jsonOut = buildPrettyHtml({ is_json: true }, '{"a":1,"b":[2,3]}');
assert.ok(jsonOut.includes("pretty-view"), "wrapper class present");
assert.ok(jsonOut.includes("pp-gutter"), "gutter present");
assert.ok(jsonOut.includes('class="language-json"'), "language class present");
// 4 formatted lines: {, "a": 1, "b": [...], } -> gutter must show 4 numbers at least
assert.ok(/<span>4<\/span>/.test(jsonOut), "line numbers computed on formatted text");
assert.ok(jsonOut.includes("&quot;a&quot;"), "HTML-escaped quotes");
console.log("Testing buildPrettyHtml (invalid json falls back to raw)...");
const bad = buildPrettyHtml({ is_json: true }, "{not valid json}");
assert.ok(bad.includes("{not valid json}"), "raw content displayed when JSON invalid");
console.log("Testing buildPrettyHtml (xss escaped)...");
const xss = buildPrettyHtml({ extension: ".py", html: "" }, 'print("<script>alert(1)</script>")');
assert.ok(!xss.includes("<script>"), "no raw script tag injected");
assert.ok(xss.includes("&lt;script&gt;"), "script tag escaped");
console.log("✅ All pretty-print tests passed successfully!");