show control chars in diffs

AuthorKonata <konata@posteo.jp>
Date
Commitacab05147233862367047e6b5733b70cd7bbf37f
Parent3ce7acf
6 files changed, 134 insertions(+), 16 deletions(-)
MREADME.md
@@ -6,7 +6,7 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g
66
77 ## Features
88
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
1010 - **Issues** — create, comment, react
1111 - **Patches** — submit git .patch files for review & comments. Admin can merge applicable patches directly into the repository.
1212 - **Templates** — issue/patch templates
Mpackage.json
@@ -7,7 +7,7 @@
77 "css:build": "bun scripts/build-css.ts",
88 "db:init": "bun run src/db/init.ts",
99 "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",
1111 "vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm",
1212 "vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js",
1313 "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 {
181181 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
182182 }
183183
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, "&amp;")
190+ .replace(/</g, "&lt;")
191+ .replace(/>/g, "&gt;")
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+
184198 // ─── Highlight a hunk ────────────────────────────────────────────────────────
185199
186200 async function highlightHunk(
@@ -188,15 +202,20 @@ async function highlightHunk(
188202 lang: string,
189203 ): Promise<string[]> {
190204 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");
192211 try {
193212 const h = await getHighlighter();
194213 const tokensByLine = h.codeToTokensWithThemes(code, {
195214 lang: lang as BundledLanguage,
196215 themes: { light: "github-light", dark: "github-dark" },
197216 });
198- const lines = tokensByLine.map((lineTokens) =>
199- lineTokens
217+ const lines = tokensByLine.map((lineTokens, i) => {
218+ const html = lineTokens
200219 .map((token) => {
201220 const style = Object.entries(token.variants)
202221 .map(
@@ -204,14 +223,17 @@ async function highlightHunk(
204223 `--shiki-${theme}:${v.color ?? "inherit"}`,
205224 )
206225 .join(";");
207- return `<span style="${style}">${escapeHtml(token.content)}</span>`;
226+ return `<span style="${style}">${escapeHtmlAndCtrl(token.content)}</span>`;
208227 })
209- .join(""),
210- );
228+ .join("");
229+ return trailingCR[i]
230+ ? html + '<span class="diff-ctrl">^M</span>'
231+ : html;
232+ });
211233 while (lines.length < hunk.lines.length) lines.push("");
212234 return lines;
213235 } catch {
214- return hunk.lines.map((l) => escapeHtml(l.content));
236+ return hunk.lines.map((l) => escapeHtmlAndCtrl(l.content));
215237 }
216238 }
217239
@@ -249,7 +271,7 @@ export async function highlightFile(
249271 const highlightedHunks =
250272 totalBytes > INLINE_MAX_BYTES
251273 ? file.hunks.map((hunk) =>
252- hunk.lines.map((l) => escapeHtml(l.content)),
274+ hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)),
253275 )
254276 : await Promise.all(
255277 file.hunks.map((hunk) => highlightHunk(hunk, lang)),
Msrc/services/highlight.ts
@@ -71,12 +71,8 @@ export function getHighlighter(): Highlighter {
7171 return highlighter;
7272 }
7373
74-// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional binary content detection
75-const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
76-
7774 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);
8076 }
8177
8278 export function detectLang(filename: string): string {
Msrc/styles/main.css
@@ -1463,6 +1463,14 @@
14631463 font-weight: 700;
14641464 }
14651465
1466+ .diff-ctrl {
1467+ color: #f0f0f0;
1468+ background: #c0392b;
1469+ border-radius: 2px;
1470+ font-weight: bold;
1471+ padding: 0 1px;
1472+ }
1473+
14661474 /* Shiki dark-mode support in diff code cells */
14671475 @media (prefers-color-scheme: dark) {
14681476 .diff-code span[style] {
Mtests/highlight.test.ts
@@ -1,5 +1,6 @@
11 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";
34
45 beforeAll(async () => {
56 await highlightStartup();
@@ -35,3 +36,94 @@ describe("detectLang", () => {
3536 test("falls back to text for unknown extension", () => expect(detectLang("foo.xyz")).toBe("text"));
3637 test("falls back to text for no extension", () => expect(detectLang("LICENSE")).toBe("text"));
3738 });
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+});