feat: fix git window and add pretty diff viewer (#178)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 58s
CI / Lint & Test (push) Successful in 16m33s
CI / Build Linux (push) Successful in 20m56s
CI / Build Windows (cross-compile) (push) Successful in 31m1s

## Summary

- **Fix git window "Not a git repository" error** — The working directory received from Claude Code is a WSL Linux path (e.g. `/home/naomi/...`), but git commands were being run as native Windows processes with `.current_dir()`. Windows can't resolve WSL paths, causing `git rev-parse --git-dir` to fail. Fixed by routing git commands through `wsl -- git -C <path>` when the working directory starts with `/`.

- **Add syntax highlighting and line numbers to diff view** — Replaced the raw `<pre>` block with a proper `DiffViewer` component featuring:
  - Old/new line number columns with correct tracking across hunks
  - Colour-coded gutter (`+`/`-`) with green/red row backgrounds
  - Syntax highlighting via `highlight.js` using the detected file language, respecting all app themes via `--hljs-*` CSS variables
  - Styled hunk headers and file headers

## New files

- `src/lib/utils/diffParser.ts` — pure diff parsing logic
- `src/lib/utils/diffParser.test.ts` — 30 tests covering all line types, line number tracking, and language detection
- `src/lib/components/DiffViewer.svelte` — the pretty diff viewer component

 This pull request was created with help from Hikari~ 🌸

Reviewed-on: #178
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #178.
This commit is contained in:
2026-03-06 09:19:16 -08:00
committed by Naomi Carrigan
parent 9af61a4a29
commit 1ae440659c
5 changed files with 668 additions and 7 deletions
+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");
});
});