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 `
${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("