show control chars in diffs
MREADME.md
| @@ -6,7 +6,7 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g | |||
|---|---|---|---|
| 6 | 6 | ||
| 7 | 7 | ## Features | |
| 8 | 8 | ||
| 9 | - | - **Repository browser** — file tree, blob view, commit log, README rendering, media previews | |
| 9 | + | - **Repository browser** — file tree, directly edit single files, blob view, commit log, README rendering, media previews | |
| 10 | 10 | - **Issues** — create, comment, react | |
| 11 | 11 | - **Patches** — submit git .patch files for review & comments. Admin can merge applicable patches directly into the repository. | |
| 12 | 12 | - **Templates** — issue/patch templates | |
Mpackage.json
| @@ -7,7 +7,7 @@ | |||
|---|---|---|---|
| 7 | 7 | "css:build": "bun scripts/build-css.ts", | |
| 8 | 8 | "db:init": "bun run src/db/init.ts", | |
| 9 | 9 | "db:seed": "bun run src/db/seed.ts", | |
| 10 | - | "test": "bun test tests/e2e.test.ts --timeout 60000", | |
| 10 | + | "test": "bun test tests/highlight.test.ts && bun test tests/e2e.test.ts --timeout 60000", | |
| 11 | 11 | "vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm", | |
| 12 | 12 | "vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js", | |
| 13 | 13 | "postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill && bun run css:build", | |
Msrc/services/diffHighlight.ts
| @@ -181,6 +181,20 @@ function escapeHtml(s: string): string { | |||
|---|---|---|---|
| 181 | 181 | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | + | // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control char rendering | |
| 185 | + | const CTRL_RE = /[\x00-\x08\x0b\x0c\x0d\x0e-\x1f]/g; | |
| 186 | + | ||
| 187 | + | function escapeHtmlAndCtrl(s: string): string { | |
| 188 | + | return s | |
| 189 | + | .replace(/&/g, "&") | |
| 190 | + | .replace(/</g, "<") | |
| 191 | + | .replace(/>/g, ">") | |
| 192 | + | .replace(CTRL_RE, (ch) => { | |
| 193 | + | const label = `^${String.fromCharCode(ch.charCodeAt(0) + 64)}`; | |
| 194 | + | return `<span class="diff-ctrl">${label}</span>`; | |
| 195 | + | }); | |
| 196 | + | } | |
| 197 | + | ||
| 184 | 198 | // ─── Highlight a hunk ──────────────────────────────────────────────────────── | |
| 185 | 199 | ||
| 186 | 200 | async function highlightHunk( | |
| @@ -188,15 +202,20 @@ async function highlightHunk( | |||
|---|---|---|---|
| 188 | 202 | lang: string, | |
| 189 | 203 | ): Promise<string[]> { | |
| 190 | 204 | if (hunk.lines.length === 0) return []; | |
| 191 | - | const code = hunk.lines.map((l) => l.content).join("\n"); | |
| 205 | + | // Strip trailing \r before joining with \n: Shiki normalises \r\n as a | |
| 206 | + | // single newline and silently drops the \r from every line except the last. | |
| 207 | + | const contents = hunk.lines.map((l) => l.content); | |
| 208 | + | const trailingCR = contents.map((c) => c.endsWith("\r")); | |
| 209 | + | const stripped = contents.map((c, i) => (trailingCR[i] ? c.slice(0, -1) : c)); | |
| 210 | + | const code = stripped.join("\n"); | |
| 192 | 211 | try { | |
| 193 | 212 | const h = await getHighlighter(); | |
| 194 | 213 | const tokensByLine = h.codeToTokensWithThemes(code, { | |
| 195 | 214 | lang: lang as BundledLanguage, | |
| 196 | 215 | themes: { light: "github-light", dark: "github-dark" }, | |
| 197 | 216 | }); | |
| 198 | - | const lines = tokensByLine.map((lineTokens) => | |
| 199 | - | lineTokens | |
| 217 | + | const lines = tokensByLine.map((lineTokens, i) => { | |
| 218 | + | const html = lineTokens | |
| 200 | 219 | .map((token) => { | |
| 201 | 220 | const style = Object.entries(token.variants) | |
| 202 | 221 | .map( | |
| @@ -204,14 +223,17 @@ async function highlightHunk( | |||
|---|---|---|---|
| 204 | 223 | `--shiki-${theme}:${v.color ?? "inherit"}`, | |
| 205 | 224 | ) | |
| 206 | 225 | .join(";"); | |
| 207 | - | return `<span style="${style}">${escapeHtml(token.content)}</span>`; | |
| 226 | + | return `<span style="${style}">${escapeHtmlAndCtrl(token.content)}</span>`; | |
| 208 | 227 | }) | |
| 209 | - | .join(""), | |
| 210 | - | ); | |
| 228 | + | .join(""); | |
| 229 | + | return trailingCR[i] | |
| 230 | + | ? html + '<span class="diff-ctrl">^M</span>' | |
| 231 | + | : html; | |
| 232 | + | }); | |
| 211 | 233 | while (lines.length < hunk.lines.length) lines.push(""); | |
| 212 | 234 | return lines; | |
| 213 | 235 | } catch { | |
| 214 | - | return hunk.lines.map((l) => escapeHtml(l.content)); | |
| 236 | + | return hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)); | |
| 215 | 237 | } | |
| 216 | 238 | } | |
| 217 | 239 | ||
| @@ -249,7 +271,7 @@ export async function highlightFile( | |||
|---|---|---|---|
| 249 | 271 | const highlightedHunks = | |
| 250 | 272 | totalBytes > INLINE_MAX_BYTES | |
| 251 | 273 | ? file.hunks.map((hunk) => | |
| 252 | - | hunk.lines.map((l) => escapeHtml(l.content)), | |
| 274 | + | hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)), | |
| 253 | 275 | ) | |
| 254 | 276 | : await Promise.all( | |
| 255 | 277 | file.hunks.map((hunk) => highlightHunk(hunk, lang)), | |
Msrc/services/highlight.ts
| @@ -71,12 +71,8 @@ export function getHighlighter(): Highlighter { | |||
|---|---|---|---|
| 71 | 71 | return highlighter; | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional binary content detection | |
| 75 | - | const BINARY_RE = /[\x00-\x08\x0e-\x1f]/; | |
| 76 | - | ||
| 77 | 74 | export function hasBinaryContent(buf: Buffer): boolean { | |
| 78 | - | const sample = buf.subarray(0, 8000); | |
| 79 | - | return BINARY_RE.test(sample.toString("binary")); | |
| 75 | + | return buf.subarray(0, 8000).includes(0); | |
| 80 | 76 | } | |
| 81 | 77 | ||
| 82 | 78 | export function detectLang(filename: string): string { | |
Msrc/styles/main.css
| @@ -1463,6 +1463,14 @@ | |||
|---|---|---|---|
| 1463 | 1463 | font-weight: 700; | |
| 1464 | 1464 | } | |
| 1465 | 1465 | ||
| 1466 | + | .diff-ctrl { | |
| 1467 | + | color: #f0f0f0; | |
| 1468 | + | background: #c0392b; | |
| 1469 | + | border-radius: 2px; | |
| 1470 | + | font-weight: bold; | |
| 1471 | + | padding: 0 1px; | |
| 1472 | + | } | |
| 1473 | + | ||
| 1466 | 1474 | /* Shiki dark-mode support in diff code cells */ | |
| 1467 | 1475 | @media (prefers-color-scheme: dark) { | |
| 1468 | 1476 | .diff-code span[style] { | |
Mtests/highlight.test.ts
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | import { describe, test, expect, beforeAll } from "bun:test"; | |
| 2 | - | import { detectLang, highlightStartup } from "../src/services/highlight.ts"; | |
| 2 | + | import { detectLang, hasBinaryContent, highlightStartup } from "../src/services/highlight.ts"; | |
| 3 | + | import { parseDiff, highlightFile } from "../src/services/diffHighlight.ts"; | |
| 3 | 4 | ||
| 4 | 5 | beforeAll(async () => { | |
| 5 | 6 | await highlightStartup(); | |
| @@ -35,3 +36,94 @@ describe("detectLang", () => { | |||
|---|---|---|---|
| 35 | 36 | test("falls back to text for unknown extension", () => expect(detectLang("foo.xyz")).toBe("text")); | |
| 36 | 37 | test("falls back to text for no extension", () => expect(detectLang("LICENSE")).toBe("text")); | |
| 37 | 38 | }); | |
| 39 | + | ||
| 40 | + | describe("hasBinaryContent", () => { | |
| 41 | + | test("detects NUL byte as binary", () => { | |
| 42 | + | expect(hasBinaryContent(Buffer.from([0x68, 0x65, 0x00, 0x6c, 0x6f]))).toBe(true); | |
| 43 | + | }); | |
| 44 | + | test("detects NUL-only buffer as binary", () => { | |
| 45 | + | expect(hasBinaryContent(Buffer.alloc(10, 0))).toBe(true); | |
| 46 | + | }); | |
| 47 | + | test("plain text is not binary", () => { | |
| 48 | + | expect(hasBinaryContent(Buffer.from("hello world\n"))).toBe(false); | |
| 49 | + | }); | |
| 50 | + | test("CR LF text is not binary", () => { | |
| 51 | + | expect(hasBinaryContent(Buffer.from("line1\r\nline2\r\n"))).toBe(false); | |
| 52 | + | }); | |
| 53 | + | test("other control chars without NUL are not binary", () => { | |
| 54 | + | // \x01, \x07 (BEL), \x1b (ESC) — git treats these as text | |
| 55 | + | expect(hasBinaryContent(Buffer.from([0x01, 0x07, 0x1b, 0x41]))).toBe(false); | |
| 56 | + | }); | |
| 57 | + | test("only checks first 8000 bytes", () => { | |
| 58 | + | // NUL appears after the 8000-byte sample window — should not be detected | |
| 59 | + | const buf = Buffer.alloc(8001, 0x41); | |
| 60 | + | buf[8000] = 0x00; | |
| 61 | + | expect(hasBinaryContent(buf)).toBe(false); | |
| 62 | + | }); | |
| 63 | + | test("detects NUL within the 8000-byte sample", () => { | |
| 64 | + | const buf = Buffer.alloc(8001, 0x41); | |
| 65 | + | buf[7999] = 0x00; | |
| 66 | + | expect(hasBinaryContent(buf)).toBe(true); | |
| 67 | + | }); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | // Build a minimal unified diff string with the given added line(s). | |
| 71 | + | function makeDiff(...lines: string[]): string { | |
| 72 | + | return [ | |
| 73 | + | "diff --git a/test.txt b/test.txt", | |
| 74 | + | "index 0000000..1111111 100644", | |
| 75 | + | "--- a/test.txt", | |
| 76 | + | "+++ b/test.txt", | |
| 77 | + | `@@ -0,0 +${lines.length} @@`, | |
| 78 | + | ...lines.map((l) => `+${l}`), | |
| 79 | + | ].join("\n"); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | describe("diff control character rendering", () => { | |
| 83 | + | test("CR renders as ^M with diff-ctrl span", async () => { | |
| 84 | + | const files = await parseDiff(makeDiff("hello\rworld")); | |
| 85 | + | const rendered = await highlightFile(files[0]!); | |
| 86 | + | const html = rendered.hunks[0]!.rows[0]!.html; | |
| 87 | + | expect(html).toContain('<span class="diff-ctrl">^M</span>'); | |
| 88 | + | }); | |
| 89 | + | ||
| 90 | + | test("trailing CR renders as ^M on every line, not just the last", async () => { | |
| 91 | + | // Simulates a CRLF line-ending change where every line ends with \r. | |
| 92 | + | // Shiki normalises \r\n → \n and would silently drop the \r from all | |
| 93 | + | // but the last line without the pre-strip fix. | |
| 94 | + | const files = await parseDiff(makeDiff("alpha\r", "beta\r", "gamma\r")); | |
| 95 | + | const rendered = await highlightFile(files[0]!); | |
| 96 | + | const rows = rendered.hunks[0]!.rows; | |
| 97 | + | for (const row of rows) { | |
| 98 | + | expect(row.html).toContain('<span class="diff-ctrl">^M</span>'); | |
| 99 | + | } | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | test("BEL (\\x07) renders as ^G", async () => { | |
| 103 | + | const files = await parseDiff(makeDiff("ring\x07bell")); | |
| 104 | + | const rendered = await highlightFile(files[0]!); | |
| 105 | + | const html = rendered.hunks[0]!.rows[0]!.html; | |
| 106 | + | expect(html).toContain('<span class="diff-ctrl">^G</span>'); | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | test("ESC (\\x1b) renders as ^[", async () => { | |
| 110 | + | const files = await parseDiff(makeDiff("\x1b[31mred\x1b[0m")); | |
| 111 | + | const rendered = await highlightFile(files[0]!); | |
| 112 | + | const html = rendered.hunks[0]!.rows[0]!.html; | |
| 113 | + | expect(html).toContain('<span class="diff-ctrl">^[</span>'); | |
| 114 | + | }); | |
| 115 | + | ||
| 116 | + | test("TAB does not render as a control char span", async () => { | |
| 117 | + | const files = await parseDiff(makeDiff("\thello")); | |
| 118 | + | const rendered = await highlightFile(files[0]!); | |
| 119 | + | const html = rendered.hunks[0]!.rows[0]!.html; | |
| 120 | + | expect(html).not.toContain("diff-ctrl"); | |
| 121 | + | }); | |
| 122 | + | ||
| 123 | + | test("normal text has no control char spans", async () => { | |
| 124 | + | const files = await parseDiff(makeDiff("hello world")); | |
| 125 | + | const rendered = await highlightFile(files[0]!); | |
| 126 | + | const html = rendered.hunks[0]!.rows[0]!.html; | |
| 127 | + | expect(html).not.toContain("diff-ctrl"); | |
| 128 | + | }); | |
| 129 | + | }); | |