diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09710bd..cb2f9e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
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).
+- **#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**
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
diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js
index 472b3a3..e05833c 100644
--- a/frontend/js/viewer.js
+++ b/frontend/js/viewer.js
@@ -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) => `${i + 1}`).join("");
+ return `
${gutter}
${escapeHtml(text)}
`;
+}
+
export function renderFile(data) {
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];
if (!data.is_image) fileActions.push(mdBtn);
if (data.is_markdown) fileActions.push(pdfBtn, exportWrap, forgeBtn, tocBtn);
if (!data.unsupported) fileActions.push(editBtn);
+ if (prettyBtn) fileActions.push(prettyBtn);
fileActions.push(openNewWindowBtn, shareBtn, bookmarkBtn);
// Assemble
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 7d8d17f..508abe6 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -1687,6 +1687,8 @@
"viewer.download": "Download",
"viewer.download_md": "Download as .md",
"viewer.download_file": "Download file",
+ "viewer.pretty": "Pretty",
+ "viewer.pretty_title": "Formatted mode: colors and line numbers",
"viewer.download_pdf": "Download as PDF",
"viewer.edit": "Edit",
"viewer.error": "Loading error",
diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json
index 9c17169..8e85bba 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -1687,6 +1687,8 @@
"viewer.download": "Télécharger",
"viewer.download_md": "Télécharger en .md",
"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.edit": "Éditer",
"viewer.error": "Erreur de chargement",
diff --git a/frontend/style.css b/frontend/style.css
index 394d350..fbeae7c 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -2052,6 +2052,47 @@ select {
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 --- */
.md-content h1,
.md-content h2,
diff --git a/tests/frontend/pretty.test.mjs b/tests/frontend/pretty.test.mjs
new file mode 100644
index 0000000..b58a97c
--- /dev/null
+++ b/tests/frontend/pretty.test.mjs
@@ -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, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+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: 'x
' }), "python");
+assert.equal(detectPrettyLang({ extension: ".sh", html: 'x
' }), "bash");
+assert.equal(detectPrettyLang({ extension: ".txt", html: "x
" }), "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(/4<\/span>/.test(jsonOut), "line numbers computed on formatted text");
+assert.ok(jsonOut.includes(""a""), "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("")');
+assert.ok(!xss.includes("