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"} + + + + + + {:else} + + + + + + + + {/if} + {/each} + +
{line.content}
{line.content}
{line.content}
{line.oldLineNumber ?? ""}{line.newLineNumber ?? ""} + {line.type === "added" ? "+" : line.type === "removed" ? "-" : ""} + {@html highlightCode(line.content)}
+{/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"; +}