diff --git a/src/lib/components/DiffViewer.svelte b/src/lib/components/DiffViewer.svelte
new file mode 100644
index 0000000..7b8eca6
--- /dev/null
+++ b/src/lib/components/DiffViewer.svelte
@@ -0,0 +1,233 @@
+
+
+{#if lines.length === 0}
+
No changes
+{:else}
+
+
+ {#each lines as line, i (i)}
+ {#if line.type === "file-header"}
+
+ {:else if line.type === "hunk-header"}
+
+ {:else if line.type === "no-newline"}
+
+ |
+ |
+ {line.content} |
+
+ {:else}
+
+ | {line.oldLineNumber ?? ""} |
+ {line.newLineNumber ?? ""} |
+
+ {line.type === "added" ? "+" : line.type === "removed" ? "-" : ""}
+ |
+
+ {@html highlightCode(line.content)} |
+
+ {/if}
+ {/each}
+
+
+{/if}
+
+
diff --git a/src/lib/components/GitPanel.svelte b/src/lib/components/GitPanel.svelte
index 058c439..6cf18cb 100644
--- a/src/lib/components/GitPanel.svelte
+++ b/src/lib/components/GitPanel.svelte
@@ -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 @@
📄 {diffFile}
- {diffContent || "(No changes)"}
+
+
+
{/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);
}
diff --git a/src/lib/utils/diffParser.test.ts b/src/lib/utils/diffParser.test.ts
new file mode 100644
index 0000000..3fb2dc2
--- /dev/null
+++ b/src/lib/utils/diffParser.test.ts
@@ -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");
+ });
+});
diff --git a/src/lib/utils/diffParser.ts b/src/lib/utils/diffParser.ts
new file mode 100644
index 0000000..d97d5d8
--- /dev/null
+++ b/src/lib/utils/diffParser.ts
@@ -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 = {
+ 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";
+}