generated from nhcarrigan/template
feat: fix git window and add pretty diff viewer (#178)
## 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:
+51
-1
@@ -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<String, String> {
|
||||
#[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();
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import hljs from "highlight.js";
|
||||
import { parseDiff, detectLanguage } from "$lib/utils/diffParser";
|
||||
|
||||
export let diffContent: string;
|
||||
export let filePath: string;
|
||||
|
||||
$: lines = diffContent ? parseDiff(diffContent) : [];
|
||||
$: language = detectLanguage(filePath);
|
||||
|
||||
function highlightCode(code: string): string {
|
||||
if (!code) return "";
|
||||
try {
|
||||
return hljs.highlight(code, { language }).value;
|
||||
} catch {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lines.length === 0}
|
||||
<div class="empty-diff">No changes</div>
|
||||
{:else}
|
||||
<table class="diff-table">
|
||||
<tbody>
|
||||
{#each lines as line, i (i)}
|
||||
{#if line.type === "file-header"}
|
||||
<tr class="line-file-header">
|
||||
<td class="line-num" colspan="2"></td>
|
||||
<td class="line-gutter"></td>
|
||||
<td class="line-code">{line.content}</td>
|
||||
</tr>
|
||||
{:else if line.type === "hunk-header"}
|
||||
<tr class="line-hunk-header">
|
||||
<td class="line-num" colspan="2"></td>
|
||||
<td class="line-gutter">⋯</td>
|
||||
<td class="line-code">{line.content}</td>
|
||||
</tr>
|
||||
{:else if line.type === "no-newline"}
|
||||
<tr class="line-no-newline">
|
||||
<td class="line-num" colspan="2"></td>
|
||||
<td class="line-gutter"></td>
|
||||
<td class="line-code">{line.content}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="line-{line.type}">
|
||||
<td class="line-num">{line.oldLineNumber ?? ""}</td>
|
||||
<td class="line-num">{line.newLineNumber ?? ""}</td>
|
||||
<td class="line-gutter">
|
||||
{line.type === "added" ? "+" : line.type === "removed" ? "-" : ""}
|
||||
</td>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Syntax highlighting requires @html; content is from trusted git diff output -->
|
||||
<td class="line-code">{@html highlightCode(line.content)}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.empty-diff {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diff-table {
|
||||
border-collapse: collapse;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diff-table tr {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.diff-table td {
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
vertical-align: top;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.line-gutter {
|
||||
width: 1.5rem;
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: bold;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Added lines */
|
||||
.line-added {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.line-added .line-num {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
color: rgba(34, 197, 94, 0.7);
|
||||
}
|
||||
|
||||
.line-added .line-gutter {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
/* Removed lines */
|
||||
.line-removed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.line-removed .line-num {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
|
||||
.line-removed .line-gutter {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
/* Hunk header */
|
||||
.line-hunk-header {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.line-hunk-header .line-code {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.line-hunk-header .line-gutter {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* File header */
|
||||
.line-file-header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.line-file-header .line-code {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
padding: 0.15rem 0.75rem;
|
||||
}
|
||||
|
||||
/* No newline */
|
||||
.line-no-newline .line-code {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — scoped to this component's table */
|
||||
.diff-table :global(.hljs-keyword),
|
||||
.diff-table :global(.hljs-selector-tag),
|
||||
.diff-table :global(.hljs-built_in),
|
||||
.diff-table :global(.hljs-name) {
|
||||
color: var(--hljs-keyword);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-string),
|
||||
.diff-table :global(.hljs-attr),
|
||||
.diff-table :global(.hljs-symbol),
|
||||
.diff-table :global(.hljs-bullet) {
|
||||
color: var(--hljs-string);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-number),
|
||||
.diff-table :global(.hljs-literal) {
|
||||
color: var(--hljs-number);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-comment),
|
||||
.diff-table :global(.hljs-quote) {
|
||||
color: var(--hljs-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-function),
|
||||
.diff-table :global(.hljs-title) {
|
||||
color: var(--hljs-function);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-type),
|
||||
.diff-table :global(.hljs-class) {
|
||||
color: var(--hljs-type);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-variable),
|
||||
.diff-table :global(.hljs-template-variable) {
|
||||
color: var(--hljs-variable);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-meta) {
|
||||
color: var(--hljs-meta);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-tag) {
|
||||
color: var(--hljs-keyword);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-attribute) {
|
||||
color: var(--hljs-function);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-params) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -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 @@
|
||||
<h3>📄 {diffFile}</h3>
|
||||
<button on:click={() => (showDiff = false)} title="Close">✕</button>
|
||||
</div>
|
||||
<pre class="diff-content">{diffContent || "(No changes)"}</pre>
|
||||
<div class="diff-content">
|
||||
<DiffViewer {diffContent} filePath={diffFile ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user