feat: add syntax highlighting and line numbers to diff view
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m0s
CI / Lint & Test (pull_request) Successful in 16m27s
CI / Build Linux (pull_request) Successful in 20m22s
CI / Build Windows (cross-compile) (pull_request) Successful in 30m28s

This commit is contained in:
2026-03-05 16:59:25 -08:00
committed by Naomi Carrigan
parent 8972194a29
commit 9e04875144
4 changed files with 617 additions and 6 deletions
+233
View File
@@ -0,0 +1,233 @@
<script lang="ts">
import hljs from "highlight.js";
import { parseDiff, detectLanguage } from "$lib/utils/diffParser";
export let diffContent: string;
export let filePath: string;
$: lines = diffContent ? parseDiff(diffContent) : [];
$: language = detectLanguage(filePath);
function highlightCode(code: string): string {
if (!code) return "";
try {
return hljs.highlight(code, { language }).value;
} catch {
return hljs.highlightAuto(code).value;
}
}
</script>
{#if lines.length === 0}
<div class="empty-diff">No changes</div>
{:else}
<table class="diff-table">
<tbody>
{#each lines as line, i (i)}
{#if line.type === "file-header"}
<tr class="line-file-header">
<td class="line-num" colspan="2"></td>
<td class="line-gutter"></td>
<td class="line-code">{line.content}</td>
</tr>
{:else if line.type === "hunk-header"}
<tr class="line-hunk-header">
<td class="line-num" colspan="2"></td>
<td class="line-gutter"></td>
<td class="line-code">{line.content}</td>
</tr>
{:else if line.type === "no-newline"}
<tr class="line-no-newline">
<td class="line-num" colspan="2"></td>
<td class="line-gutter"></td>
<td class="line-code">{line.content}</td>
</tr>
{:else}
<tr class="line-{line.type}">
<td class="line-num">{line.oldLineNumber ?? ""}</td>
<td class="line-num">{line.newLineNumber ?? ""}</td>
<td class="line-gutter">
{line.type === "added" ? "+" : line.type === "removed" ? "-" : ""}
</td>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Syntax highlighting requires @html; content is from trusted git diff output -->
<td class="line-code">{@html highlightCode(line.content)}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
<style>
.empty-diff {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.85rem;
}
.diff-table {
border-collapse: collapse;
min-width: 100%;
width: max-content;
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.5;
}
.diff-table tr {
border: none;
}
.diff-table td {
padding: 0;
white-space: pre;
vertical-align: top;
border: none;
}
.line-num {
width: 3.5rem;
min-width: 3.5rem;
color: var(--text-secondary);
text-align: right;
user-select: none;
border-right: 1px solid var(--border-color);
opacity: 0.6;
font-size: 0.75rem;
padding: 0 0.4rem;
}
.line-gutter {
width: 1.5rem;
min-width: 1.5rem;
text-align: center;
user-select: none;
font-weight: bold;
padding: 0 0.25rem;
}
.line-code {
padding: 0 0.75rem;
}
/* Added lines */
.line-added {
background: rgba(34, 197, 94, 0.1);
}
.line-added .line-num {
background: rgba(34, 197, 94, 0.08);
color: rgba(34, 197, 94, 0.7);
}
.line-added .line-gutter {
color: #22c55e;
background: rgba(34, 197, 94, 0.18);
}
/* Removed lines */
.line-removed {
background: rgba(239, 68, 68, 0.1);
}
.line-removed .line-num {
background: rgba(239, 68, 68, 0.08);
color: rgba(239, 68, 68, 0.7);
}
.line-removed .line-gutter {
color: #ef4444;
background: rgba(239, 68, 68, 0.18);
}
/* Hunk header */
.line-hunk-header {
background: rgba(99, 102, 241, 0.12);
}
.line-hunk-header .line-code {
color: var(--text-secondary);
font-style: italic;
}
.line-hunk-header .line-gutter {
color: var(--text-secondary);
}
/* File header */
.line-file-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.line-file-header .line-code {
color: var(--text-secondary);
font-weight: 500;
padding: 0.15rem 0.75rem;
}
/* No newline */
.line-no-newline .line-code {
color: var(--text-secondary);
font-style: italic;
}
/* Syntax highlighting — scoped to this component's table */
.diff-table :global(.hljs-keyword),
.diff-table :global(.hljs-selector-tag),
.diff-table :global(.hljs-built_in),
.diff-table :global(.hljs-name) {
color: var(--hljs-keyword);
}
.diff-table :global(.hljs-string),
.diff-table :global(.hljs-attr),
.diff-table :global(.hljs-symbol),
.diff-table :global(.hljs-bullet) {
color: var(--hljs-string);
}
.diff-table :global(.hljs-number),
.diff-table :global(.hljs-literal) {
color: var(--hljs-number);
}
.diff-table :global(.hljs-comment),
.diff-table :global(.hljs-quote) {
color: var(--hljs-comment);
font-style: italic;
}
.diff-table :global(.hljs-function),
.diff-table :global(.hljs-title) {
color: var(--hljs-function);
}
.diff-table :global(.hljs-type),
.diff-table :global(.hljs-class) {
color: var(--hljs-type);
}
.diff-table :global(.hljs-variable),
.diff-table :global(.hljs-template-variable) {
color: var(--hljs-variable);
}
.diff-table :global(.hljs-meta) {
color: var(--hljs-meta);
}
.diff-table :global(.hljs-tag) {
color: var(--hljs-keyword);
}
.diff-table :global(.hljs-attribute) {
color: var(--hljs-function);
}
.diff-table :global(.hljs-params) {
color: var(--text-primary);
}
</style>
+4 -6
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { onMount, onDestroy } from "svelte";
import { claudeStore } from "$lib/stores/claude";
import DiffViewer from "$lib/components/DiffViewer.svelte";
interface GitFileChange {
path: string;
@@ -600,7 +601,9 @@
<h3>📄 {diffFile}</h3>
<button on:click={() => (showDiff = false)} title="Close"></button>
</div>
<pre class="diff-content">{diffContent || "(No changes)"}</pre>
<div class="diff-content">
<DiffViewer {diffContent} filePath={diffFile ?? ""} />
</div>
</div>
</div>
{/if}
@@ -1096,12 +1099,7 @@
.diff-content {
flex: 1;
overflow: auto;
padding: 1rem;
margin: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
background: var(--bg-primary);
}
</style>
+269
View File
@@ -0,0 +1,269 @@
import { describe, it, expect } from "vitest";
import { parseDiff, detectLanguage } from "./diffParser";
describe("parseDiff", () => {
describe("empty input", () => {
it("returns an empty array for an empty string", () => {
expect(parseDiff("")).toEqual([]);
});
});
describe("file header lines", () => {
it("classifies diff --git lines as file-header", () => {
const result = parseDiff("diff --git a/foo.ts b/foo.ts");
expect(result).toHaveLength(1);
expect(result[0].type).toBe("file-header");
expect(result[0].oldLineNumber).toBeNull();
expect(result[0].newLineNumber).toBeNull();
});
it("classifies index lines as file-header", () => {
const result = parseDiff("index abc123..def456 100644");
expect(result[0].type).toBe("file-header");
});
it("classifies --- lines as file-header", () => {
const result = parseDiff("--- a/foo.ts");
expect(result[0].type).toBe("file-header");
});
it("classifies +++ lines as file-header", () => {
const result = parseDiff("+++ b/foo.ts");
expect(result[0].type).toBe("file-header");
});
it("classifies new file mode lines as file-header", () => {
const result = parseDiff("new file mode 100644");
expect(result[0].type).toBe("file-header");
});
it("classifies deleted file mode lines as file-header", () => {
const result = parseDiff("deleted file mode 100644");
expect(result[0].type).toBe("file-header");
});
});
describe("hunk header lines", () => {
it("classifies @@ lines as hunk-header", () => {
const result = parseDiff("@@ -1,5 +1,7 @@");
expect(result).toHaveLength(1);
expect(result[0].type).toBe("hunk-header");
expect(result[0].content).toBe("@@ -1,5 +1,7 @@");
expect(result[0].oldLineNumber).toBeNull();
expect(result[0].newLineNumber).toBeNull();
});
it("sets line counters from the hunk header", () => {
const diff = "@@ -10,3 +20,3 @@\n-old line\n+new line\n unchanged";
const result = parseDiff(diff);
const removed = result.find((l) => l.type === "removed");
const added = result.find((l) => l.type === "added");
const context = result.find((l) => l.type === "context");
expect(removed?.oldLineNumber).toBe(10);
expect(added?.newLineNumber).toBe(20);
expect(context?.oldLineNumber).toBe(11);
expect(context?.newLineNumber).toBe(21);
});
it("handles hunk headers with no count (single line, e.g. -1 +1)", () => {
const diff = "@@ -1 +1 @@\n-old\n+new";
const result = parseDiff(diff);
const removed = result.find((l) => l.type === "removed");
const added = result.find((l) => l.type === "added");
expect(removed?.oldLineNumber).toBe(1);
expect(added?.newLineNumber).toBe(1);
});
});
describe("added lines", () => {
it("classifies + lines as added", () => {
const result = parseDiff("@@ -1,1 +1,1 @@\n+new line");
const added = result.find((l) => l.type === "added");
expect(added).toBeDefined();
expect(added?.content).toBe("new line");
});
it("strips the leading + from content", () => {
const result = parseDiff("@@ -1,1 +1,1 @@\n+ indented code");
const added = result.find((l) => l.type === "added");
expect(added?.content).toBe(" indented code");
});
it("has null oldLineNumber for added lines", () => {
const result = parseDiff("@@ -1,1 +1,1 @@\n+line");
const added = result.find((l) => l.type === "added");
expect(added?.oldLineNumber).toBeNull();
});
it("increments newLineNumber across multiple added lines", () => {
const diff = "@@ -1,1 +1,3 @@\n+first\n+second\n+third";
const result = parseDiff(diff);
const added = result.filter((l) => l.type === "added");
expect(added[0].newLineNumber).toBe(1);
expect(added[1].newLineNumber).toBe(2);
expect(added[2].newLineNumber).toBe(3);
});
});
describe("removed lines", () => {
it("classifies - lines as removed", () => {
const result = parseDiff("@@ -1,1 +1,1 @@\n-old line");
const removed = result.find((l) => l.type === "removed");
expect(removed).toBeDefined();
expect(removed?.content).toBe("old line");
});
it("strips the leading - from content", () => {
const result = parseDiff("@@ -1,1 +1,1 @@\n- indented code");
const removed = result.find((l) => l.type === "removed");
expect(removed?.content).toBe(" indented code");
});
it("has null newLineNumber for removed lines", () => {
const result = parseDiff("@@ -1,1 +1,1 @@\n-line");
const removed = result.find((l) => l.type === "removed");
expect(removed?.newLineNumber).toBeNull();
});
it("increments oldLineNumber across multiple removed lines", () => {
const diff = "@@ -5,3 +5,1 @@\n-first\n-second\n-third";
const result = parseDiff(diff);
const removed = result.filter((l) => l.type === "removed");
expect(removed[0].oldLineNumber).toBe(5);
expect(removed[1].oldLineNumber).toBe(6);
expect(removed[2].oldLineNumber).toBe(7);
});
});
describe("context lines", () => {
it("classifies space-prefixed lines as context", () => {
const result = parseDiff("@@ -1,1 +1,1 @@\n unchanged line");
const context = result.find((l) => l.type === "context");
expect(context).toBeDefined();
expect(context?.content).toBe("unchanged line");
});
it("has both line numbers for context lines", () => {
const diff = "@@ -3,1 +5,1 @@\n context";
const result = parseDiff(diff);
const context = result.find((l) => l.type === "context");
expect(context?.oldLineNumber).toBe(3);
expect(context?.newLineNumber).toBe(5);
});
it("increments both line numbers across context lines", () => {
const diff = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
const result = parseDiff(diff);
const contexts = result.filter((l) => l.type === "context");
expect(contexts[0].oldLineNumber).toBe(1);
expect(contexts[0].newLineNumber).toBe(1);
expect(contexts[2].oldLineNumber).toBe(3);
expect(contexts[2].newLineNumber).toBe(3);
});
});
describe("no-newline marker", () => {
it("classifies the no-newline marker correctly", () => {
const result = parseDiff("\\ No newline at end of file");
expect(result[0].type).toBe("no-newline");
expect(result[0].content).toBe("\\ No newline at end of file");
});
});
describe("line number tracking across mixed lines", () => {
it("tracks old and new line numbers correctly through a realistic diff", () => {
const diff = [
"@@ -10,6 +10,7 @@",
" context one",
"-removed line",
"+added line one",
"+added line two",
" context two",
" context three",
].join("\n");
const result = parseDiff(diff);
const lines = result.filter((l) => l.type !== "hunk-header");
expect(lines[0]).toMatchObject({ type: "context", oldLineNumber: 10, newLineNumber: 10 });
expect(lines[1]).toMatchObject({ type: "removed", oldLineNumber: 11, newLineNumber: null });
expect(lines[2]).toMatchObject({ type: "added", oldLineNumber: null, newLineNumber: 11 });
expect(lines[3]).toMatchObject({ type: "added", oldLineNumber: null, newLineNumber: 12 });
expect(lines[4]).toMatchObject({ type: "context", oldLineNumber: 12, newLineNumber: 13 });
expect(lines[5]).toMatchObject({ type: "context", oldLineNumber: 13, newLineNumber: 14 });
});
it("resets line counters on a second hunk header", () => {
const diff = [
"@@ -1,1 +1,1 @@",
" context",
"@@ -50,1 +50,1 @@",
" second hunk context",
].join("\n");
const result = parseDiff(diff);
const secondContext = result[result.length - 1];
expect(secondContext.type).toBe("context");
expect(secondContext.oldLineNumber).toBe(50);
expect(secondContext.newLineNumber).toBe(50);
});
});
});
describe("detectLanguage", () => {
it("detects TypeScript from .ts extension", () => {
expect(detectLanguage("src/foo.ts")).toBe("typescript");
});
it("detects TypeScript from .tsx extension", () => {
expect(detectLanguage("src/foo.tsx")).toBe("typescript");
});
it("detects JavaScript from .js extension", () => {
expect(detectLanguage("src/foo.js")).toBe("javascript");
});
it("detects Rust from .rs extension", () => {
expect(detectLanguage("src-tauri/src/lib.rs")).toBe("rust");
});
it("detects Python from .py extension", () => {
expect(detectLanguage("script.py")).toBe("python");
});
it("detects CSS from .css extension", () => {
expect(detectLanguage("style.css")).toBe("css");
});
it("detects JSON from .json extension", () => {
expect(detectLanguage("package.json")).toBe("json");
});
it("detects YAML from .yaml extension", () => {
expect(detectLanguage("config.yaml")).toBe("yaml");
});
it("detects YAML from .yml extension", () => {
expect(detectLanguage(".github/workflows/ci.yml")).toBe("yaml");
});
it("detects bash from .sh extension", () => {
expect(detectLanguage("check-all.sh")).toBe("bash");
});
it("uses plaintext for unknown extensions", () => {
expect(detectLanguage("file.xyz")).toBe("plaintext");
});
it("uses plaintext when there is no extension", () => {
expect(detectLanguage("Makefile")).toBe("plaintext");
});
it("handles paths with multiple dots correctly (uses last segment)", () => {
expect(detectLanguage("my.config.ts")).toBe("typescript");
});
it("is case-insensitive for extension detection", () => {
expect(detectLanguage("README.MD")).toBe("markdown");
});
});
+111
View File
@@ -0,0 +1,111 @@
export type DiffLineType =
| "file-header"
| "hunk-header"
| "added"
| "removed"
| "context"
| "no-newline";
export interface ParsedDiffLine {
type: DiffLineType;
content: string;
oldLineNumber: number | null;
newLineNumber: number | null;
}
const FILE_HEADER_PREFIXES = [
"diff ",
"index ",
"--- ",
"+++ ",
"new file",
"deleted file",
"old mode",
"new mode",
"rename ",
"similarity ",
];
export function parseDiff(diffContent: string): ParsedDiffLine[] {
const result: ParsedDiffLine[] = [];
let oldLine = 0;
let newLine = 0;
for (const line of diffContent.split("\n")) {
if (FILE_HEADER_PREFIXES.some((prefix) => line.startsWith(prefix))) {
result.push({ type: "file-header", content: line, oldLineNumber: null, newLineNumber: null });
} else if (line.startsWith("@@")) {
const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match) {
oldLine = parseInt(match[1], 10);
newLine = parseInt(match[2], 10);
}
result.push({ type: "hunk-header", content: line, oldLineNumber: null, newLineNumber: null });
} else if (line.startsWith("+")) {
result.push({
type: "added",
content: line.slice(1),
oldLineNumber: null,
newLineNumber: newLine++,
});
} else if (line.startsWith("-")) {
result.push({
type: "removed",
content: line.slice(1),
oldLineNumber: oldLine++,
newLineNumber: null,
});
} else if (line.startsWith(" ")) {
result.push({
type: "context",
content: line.slice(1),
oldLineNumber: oldLine++,
newLineNumber: newLine++,
});
} else if (line === "\\ No newline at end of file") {
result.push({ type: "no-newline", content: line, oldLineNumber: null, newLineNumber: null });
}
// Skip empty trailing lines
}
return result;
}
const EXTENSION_MAP: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
rs: "rust",
py: "python",
svelte: "xml",
css: "css",
scss: "scss",
less: "less",
html: "html",
json: "json",
md: "markdown",
toml: "ini",
yaml: "yaml",
yml: "yaml",
sh: "bash",
bash: "bash",
go: "go",
java: "java",
cpp: "cpp",
c: "c",
rb: "ruby",
php: "php",
sql: "sql",
kt: "kotlin",
swift: "swift",
cs: "csharp",
r: "r",
lua: "lua",
xml: "xml",
};
export function detectLanguage(filePath: string): string {
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
return EXTENSION_MAP[ext] ?? "plaintext";
}