generated from nhcarrigan/template
1ae440659c
## 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>
270 lines
9.6 KiB
TypeScript
270 lines
9.6 KiB
TypeScript
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");
|
|
});
|
|
});
|