From 1ae440659cfc3fe214bb6e21f2e374508fa348a1 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 09:19:16 -0800 Subject: [PATCH] feat: fix git window and add pretty diff viewer (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ` when the working directory starts with `/`. - **Add syntax highlighting and line numbers to diff view** — Replaced the raw `
` 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: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/178
Co-authored-by: Hikari 
Co-committed-by: Hikari 
---
 src-tauri/src/git.rs                 |  52 +++++-
 src/lib/components/DiffViewer.svelte | 233 +++++++++++++++++++++++
 src/lib/components/GitPanel.svelte   |  10 +-
 src/lib/utils/diffParser.test.ts     | 269 +++++++++++++++++++++++++++
 src/lib/utils/diffParser.ts          | 111 +++++++++++
 5 files changed, 668 insertions(+), 7 deletions(-)
 create mode 100644 src/lib/components/DiffViewer.svelte
 create mode 100644 src/lib/utils/diffParser.test.ts
 create mode 100644 src/lib/utils/diffParser.ts

diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs
index 356b16a..8d1e264 100644
--- a/src-tauri/src/git.rs
+++ b/src-tauri/src/git.rs
@@ -1,6 +1,7 @@
 use serde::{Deserialize, Serialize};
 use std::process::Command;
 
+#[cfg(target_os = "windows")]
 use crate::process_ext::HideWindow;
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -37,9 +38,38 @@ pub struct GitLogEntry {
     pub message: String,
 }
 
+/// Builds the WSL argument list for running a git command at a Linux path.
+/// Extracted for testability without requiring WSL to be available.
+#[cfg(any(target_os = "windows", test))]
+fn build_wsl_git_args<'a>(working_dir: &'a str, args: &[&'a str]) -> Vec<&'a str> {
+    let mut wsl_args = vec!["--", "git", "-C", working_dir];
+    wsl_args.extend_from_slice(args);
+    wsl_args
+}
+
 fn run_git_command(working_dir: &str, args: &[&str]) -> Result {
+    #[cfg(target_os = "windows")]
+    let output = {
+        if working_dir.starts_with('/') {
+            // WSL/Linux path — run git through WSL so it can resolve the path correctly.
+            let wsl_args = build_wsl_git_args(working_dir, args);
+            Command::new("wsl")
+                .hide_window()
+                .args(&wsl_args)
+                .output()
+                .map_err(|e| format!("Failed to execute git via WSL: {}", e))?
+        } else {
+            Command::new("git")
+                .hide_window()
+                .args(args)
+                .current_dir(working_dir)
+                .output()
+                .map_err(|e| format!("Failed to execute git: {}", e))?
+        }
+    };
+
+    #[cfg(not(target_os = "windows"))]
     let output = Command::new("git")
-        .hide_window()
         .args(args)
         .current_dir(working_dir)
         .output()
@@ -297,6 +327,26 @@ mod tests {
     use std::io::Write;
     use tempfile::TempDir;
 
+    // ==================== build_wsl_git_args tests ====================
+
+    #[test]
+    fn test_build_wsl_git_args_structure() {
+        let args = build_wsl_git_args("/home/naomi/code/project", &["status", "--porcelain=v1"]);
+        assert_eq!(args[0], "--");
+        assert_eq!(args[1], "git");
+        assert_eq!(args[2], "-C");
+        assert_eq!(args[3], "/home/naomi/code/project");
+        assert_eq!(args[4], "status");
+        assert_eq!(args[5], "--porcelain=v1");
+        assert_eq!(args.len(), 6);
+    }
+
+    #[test]
+    fn test_build_wsl_git_args_no_extra_args() {
+        let args = build_wsl_git_args("/home/user/repo", &["init"]);
+        assert_eq!(args, vec!["--", "git", "-C", "/home/user/repo", "init"]);
+    }
+
     // Helper to create a git repository in a temp directory
     fn create_test_repo() -> TempDir {
         let temp_dir = TempDir::new().unwrap();
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"; +}