feat(git-diff): server-side syntax highlighting with full file context

- Move syntax highlighting from client to server for proper AST context
- Add highlighted_diff_request/response message types
- Create diff-highlighter and syntax-highlighter utils on server
- Fix TSX dialect to use "ts jsx" for proper TypeScript parsing
- Redesign diff UI: card-style file sections, GitHub Dark theme colors
- Reduce visual clutter: smaller padding, subtle backgrounds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-01-06 15:26:09 +07:00
co-authored by Claude Opus 4.5
parent a187f26e1b
commit ed25db4e22
13 changed files with 1653 additions and 224 deletions
@@ -0,0 +1,272 @@
import { describe, expect, it } from "vitest";
import {
parseDiff,
reconstructNewFile,
reconstructOldFile,
highlightDiffFile,
parseAndHighlightDiff,
type ParsedDiffFile,
type DiffHunk,
} from "./diff-highlighter";
const SIMPLE_DIFF = `diff --git a/example.ts b/example.ts
index 1234567..abcdefg 100644
--- a/example.ts
+++ b/example.ts
@@ -1,5 +1,5 @@
const foo = 1;
-const bar = 2;
+const bar = 3;
const baz = foo + bar;
export { foo, bar, baz };
`;
const MULTI_HUNK_DIFF = `diff --git a/example.ts b/example.ts
index 1234567..abcdefg 100644
--- a/example.ts
+++ b/example.ts
@@ -1,3 +1,3 @@
const foo = 1;
-const bar = 2;
+const bar = 3;
const baz = foo + bar;
@@ -10,3 +10,4 @@
function greet(name: string) {
return "Hello, " + name;
}
+export { greet };
`;
const NEW_FILE_DIFF = `diff --git a/newfile.ts b/newfile.ts
new file mode 100644
index 0000000..1234567
--- /dev/null
+++ b/newfile.ts
@@ -0,0 +1,3 @@
+const x = 1;
+const y = 2;
+export { x, y };
`;
const DELETED_FILE_DIFF = `diff --git a/oldfile.ts b/oldfile.ts
deleted file mode 100644
index 1234567..0000000
--- a/oldfile.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-const legacy = true;
-export { legacy };
`;
describe("parseDiff", () => {
it("parses a simple diff with one hunk", () => {
const files = parseDiff(SIMPLE_DIFF);
expect(files).toHaveLength(1);
expect(files[0].path).toBe("example.ts");
expect(files[0].isNew).toBe(false);
expect(files[0].isDeleted).toBe(false);
expect(files[0].additions).toBe(1);
expect(files[0].deletions).toBe(1);
expect(files[0].hunks).toHaveLength(1);
const hunk = files[0].hunks[0];
expect(hunk.oldStart).toBe(1);
expect(hunk.oldCount).toBe(5);
expect(hunk.newStart).toBe(1);
expect(hunk.newCount).toBe(5);
// Header + 5 content lines (1 context, 1 remove, 1 add, 1 context, 1 blank context, 1 context)
expect(hunk.lines[0].type).toBe("header");
expect(hunk.lines[1].type).toBe("context");
expect(hunk.lines[1].content).toBe("const foo = 1;");
expect(hunk.lines[2].type).toBe("remove");
expect(hunk.lines[2].content).toBe("const bar = 2;");
expect(hunk.lines[3].type).toBe("add");
expect(hunk.lines[3].content).toBe("const bar = 3;");
});
it("parses a diff with multiple hunks", () => {
const files = parseDiff(MULTI_HUNK_DIFF);
expect(files).toHaveLength(1);
expect(files[0].hunks).toHaveLength(2);
expect(files[0].hunks[0].oldStart).toBe(1);
expect(files[0].hunks[0].newStart).toBe(1);
expect(files[0].hunks[1].oldStart).toBe(10);
expect(files[0].hunks[1].newStart).toBe(10);
});
it("parses a new file diff", () => {
const files = parseDiff(NEW_FILE_DIFF);
expect(files).toHaveLength(1);
expect(files[0].path).toBe("newfile.ts");
expect(files[0].isNew).toBe(true);
expect(files[0].isDeleted).toBe(false);
expect(files[0].additions).toBe(3);
expect(files[0].deletions).toBe(0);
});
it("parses a deleted file diff", () => {
const files = parseDiff(DELETED_FILE_DIFF);
expect(files).toHaveLength(1);
expect(files[0].path).toBe("oldfile.ts");
expect(files[0].isNew).toBe(false);
expect(files[0].isDeleted).toBe(true);
expect(files[0].additions).toBe(0);
expect(files[0].deletions).toBe(2);
});
it("returns empty array for empty input", () => {
expect(parseDiff("")).toEqual([]);
expect(parseDiff(" ")).toEqual([]);
});
});
describe("reconstructNewFile", () => {
it("reconstructs the new file version from hunks", () => {
const files = parseDiff(SIMPLE_DIFF);
const newFile = reconstructNewFile(files[0].hunks);
expect(newFile.get(1)).toBe("const foo = 1;");
expect(newFile.get(2)).toBe("const bar = 3;"); // Changed line
expect(newFile.get(3)).toBe("const baz = foo + bar;");
// Note: blank lines in diffs should have a space prefix to be parsed
// as context lines. In this test the blank line has no prefix so it's
// not included. That's OK - real git diffs have the space.
expect(newFile.get(4)).toBe("export { foo, bar, baz };");
// Old value should not be present
expect(Array.from(newFile.values())).not.toContain("const bar = 2;");
});
it("handles new file (all additions)", () => {
const files = parseDiff(NEW_FILE_DIFF);
const newFile = reconstructNewFile(files[0].hunks);
expect(newFile.get(1)).toBe("const x = 1;");
expect(newFile.get(2)).toBe("const y = 2;");
expect(newFile.get(3)).toBe("export { x, y };");
});
});
describe("reconstructOldFile", () => {
it("reconstructs the old file version from hunks", () => {
const files = parseDiff(SIMPLE_DIFF);
const oldFile = reconstructOldFile(files[0].hunks);
expect(oldFile.get(1)).toBe("const foo = 1;");
expect(oldFile.get(2)).toBe("const bar = 2;"); // Original line
expect(oldFile.get(3)).toBe("const baz = foo + bar;");
// New value should not be present
expect(Array.from(oldFile.values())).not.toContain("const bar = 3;");
});
it("handles deleted file (all removals)", () => {
const files = parseDiff(DELETED_FILE_DIFF);
const oldFile = reconstructOldFile(files[0].hunks);
expect(oldFile.get(1)).toBe("const legacy = true;");
expect(oldFile.get(2)).toBe("export { legacy };");
});
});
describe("highlightDiffFile", () => {
it("adds syntax highlighting tokens to TypeScript code", () => {
const files = parseDiff(SIMPLE_DIFF);
const highlighted = highlightDiffFile(files[0]);
// Check that tokens are added
const hunk = highlighted.hunks[0];
// First content line: "const foo = 1;"
const constLine = hunk.lines[1];
expect(constLine.tokens).toBeDefined();
expect(constLine.tokens!.length).toBeGreaterThan(0);
// Should have a "keyword" token for "const"
const constToken = constLine.tokens!.find((t) => t.text === "const");
expect(constToken).toBeDefined();
expect(constToken!.style).toBe("keyword");
// Should have a "number" token for "1"
const numberToken = constLine.tokens!.find((t) => t.text === "1");
expect(numberToken).toBeDefined();
expect(numberToken!.style).toBe("number");
});
it("highlights both added and removed lines correctly", () => {
const files = parseDiff(SIMPLE_DIFF);
const highlighted = highlightDiffFile(files[0]);
const hunk = highlighted.hunks[0];
// Removed line: "const bar = 2;"
const removedLine = hunk.lines.find(
(l) => l.type === "remove" && l.content.includes("bar")
);
expect(removedLine?.tokens).toBeDefined();
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
expect(removedNumber?.style).toBe("number");
// Added line: "const bar = 3;"
const addedLine = hunk.lines.find(
(l) => l.type === "add" && l.content.includes("bar")
);
expect(addedLine?.tokens).toBeDefined();
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
expect(addedNumber?.style).toBe("number");
});
it("does not modify unsupported file types", () => {
const file: ParsedDiffFile = {
path: "README.txt",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [
{ type: "header", content: "@@ -1,1 +1,2 @@" },
{ type: "context", content: "Hello" },
{ type: "add", content: "World" },
],
},
],
};
const highlighted = highlightDiffFile(file);
// Lines should not have tokens
expect(highlighted.hunks[0].lines[1].tokens).toBeUndefined();
expect(highlighted.hunks[0].lines[2].tokens).toBeUndefined();
});
});
describe("parseAndHighlightDiff", () => {
it("parses and highlights in one step", () => {
const files = parseAndHighlightDiff(SIMPLE_DIFF);
expect(files).toHaveLength(1);
expect(files[0].hunks[0].lines[1].tokens).toBeDefined();
});
it("handles multiple files", () => {
const multiFileDiff = SIMPLE_DIFF + "\n" + NEW_FILE_DIFF;
const files = parseAndHighlightDiff(multiFileDiff);
expect(files).toHaveLength(2);
expect(files[0].path).toBe("example.ts");
expect(files[1].path).toBe("newfile.ts");
});
});
+279
View File
@@ -0,0 +1,279 @@
import {
highlightCode,
isLanguageSupported,
type HighlightToken,
} from "./syntax-highlighter";
export interface DiffLine {
type: "add" | "remove" | "context" | "header";
content: string;
lineNumber?: number; // Line number in the original/new file
tokens?: HighlightToken[];
}
export interface DiffHunk {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: DiffLine[];
}
export interface ParsedDiffFile {
path: string;
isNew: boolean;
isDeleted: boolean;
additions: number;
deletions: number;
hunks: DiffHunk[];
}
/**
* Parse a unified diff into structured data
*/
export function parseDiff(diffText: string): ParsedDiffFile[] {
if (!diffText || diffText.trim().length === 0) {
return [];
}
const files: ParsedDiffFile[] = [];
const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
for (const section of fileSections) {
const lines = section.split("\n");
const firstLine = lines[0];
// Detect new/deleted file
const isNew =
section.includes("new file mode") ||
section.includes("--- /dev/null");
const isDeleted =
section.includes("deleted file mode") ||
section.includes("+++ /dev/null");
// Extract path
let path = "unknown";
const pathMatch = firstLine.match(/a\/(.*?) b\//);
if (pathMatch) {
path = pathMatch[1];
} else {
const newFileMatch = firstLine.match(/b\/(.+)$/);
if (newFileMatch) {
path = newFileMatch[1];
}
}
const hunks: DiffHunk[] = [];
let currentHunk: DiffHunk | null = null;
let additions = 0;
let deletions = 0;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
// Skip metadata lines
if (line.startsWith("index ")) continue;
if (line.startsWith("--- ")) continue;
if (line.startsWith("+++ ")) continue;
if (line.startsWith("new file mode")) continue;
if (line.startsWith("deleted file mode")) continue;
// Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@
const hunkMatch = line.match(
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
);
if (hunkMatch) {
if (currentHunk) {
hunks.push(currentHunk);
}
currentHunk = {
oldStart: parseInt(hunkMatch[1], 10),
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
newStart: parseInt(hunkMatch[3], 10),
newCount: parseInt(hunkMatch[4] ?? "1", 10),
lines: [{ type: "header", content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }],
};
continue;
}
if (!currentHunk) continue;
if (line.startsWith("+")) {
currentHunk.lines.push({ type: "add", content: line.slice(1) });
additions++;
} else if (line.startsWith("-")) {
currentHunk.lines.push({ type: "remove", content: line.slice(1) });
deletions++;
} else if (line.startsWith(" ")) {
currentHunk.lines.push({ type: "context", content: line.slice(1) });
} else if (line.length > 0 && !line.startsWith("\\")) {
// Non-empty line that's not a "\ No newline" marker
currentHunk.lines.push({ type: "context", content: line });
}
}
if (currentHunk) {
hunks.push(currentHunk);
}
files.push({ path, isNew, isDeleted, additions, deletions, hunks });
}
return files;
}
/**
* Reconstruct the "new" version of a file from diff hunks.
* Returns a map of new line numbers to their content.
*/
export function reconstructNewFile(hunks: DiffHunk[]): Map<number, string> {
const lines = new Map<number, string>();
for (const hunk of hunks) {
let newLineNum = hunk.newStart;
for (const line of hunk.lines) {
if (line.type === "header") continue;
if (line.type === "add" || line.type === "context") {
lines.set(newLineNum, line.content);
newLineNum++;
}
// Remove lines don't appear in the new file
}
}
return lines;
}
/**
* Reconstruct the "old" version of a file from diff hunks.
* Returns a map of old line numbers to their content.
*/
export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
const lines = new Map<number, string>();
for (const hunk of hunks) {
let oldLineNum = hunk.oldStart;
for (const line of hunk.lines) {
if (line.type === "header") continue;
if (line.type === "remove" || line.type === "context") {
lines.set(oldLineNum, line.content);
oldLineNum++;
}
// Add lines don't appear in the old file
}
}
return lines;
}
/**
* Apply syntax highlighting to diff hunks.
*
* Strategy:
* 1. Reconstruct both old and new file versions from the hunks
* 2. Highlight each version as a complete file
* 3. Map highlighted tokens back to diff lines using line numbers
*/
export function highlightDiffFile(file: ParsedDiffFile): ParsedDiffFile {
if (!isLanguageSupported(file.path)) {
return file;
}
// Reconstruct both versions
const newFileLines = reconstructNewFile(file.hunks);
const oldFileLines = reconstructOldFile(file.hunks);
// Build complete file content strings for highlighting
const newFileContent = buildFileContent(newFileLines);
const oldFileContent = buildFileContent(oldFileLines);
// Highlight both versions
const newHighlighted = highlightCode(newFileContent, file.path);
const oldHighlighted = highlightCode(oldFileContent, file.path);
// Build lookup maps: line number -> tokens
const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted);
const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted);
// Apply tokens to hunks
const highlightedHunks = file.hunks.map((hunk) => {
let oldLineNum = hunk.oldStart;
let newLineNum = hunk.newStart;
const highlightedLines = hunk.lines.map((line): DiffLine => {
if (line.type === "header") {
return line;
}
let tokens: HighlightToken[] | undefined;
if (line.type === "add") {
tokens = newTokensByLine.get(newLineNum);
newLineNum++;
} else if (line.type === "remove") {
tokens = oldTokensByLine.get(oldLineNum);
oldLineNum++;
} else if (line.type === "context") {
// Context lines exist in both - use new file version
tokens = newTokensByLine.get(newLineNum);
oldLineNum++;
newLineNum++;
}
return tokens ? { ...line, tokens } : line;
});
return { ...hunk, lines: highlightedLines };
});
return { ...file, hunks: highlightedHunks };
}
function buildFileContent(lineMap: Map<number, string>): string {
if (lineMap.size === 0) return "";
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b);
const minLine = lineNumbers[0];
const maxLine = lineNumbers[lineNumbers.length - 1];
const lines: string[] = [];
for (let i = minLine; i <= maxLine; i++) {
lines.push(lineMap.get(i) ?? "");
}
return lines.join("\n");
}
function buildTokenLookup(
lineMap: Map<number, string>,
highlighted: HighlightToken[][]
): Map<number, HighlightToken[]> {
const lookup = new Map<number, HighlightToken[]>();
if (lineMap.size === 0) return lookup;
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b);
const minLine = lineNumbers[0];
// highlighted array is 0-indexed, line numbers are 1-indexed
for (let i = 0; i < highlighted.length; i++) {
const lineNum = minLine + i;
if (lineMap.has(lineNum)) {
lookup.set(lineNum, highlighted[i]);
}
}
return lookup;
}
/**
* Parse and highlight a complete diff
*/
export function parseAndHighlightDiff(diffText: string): ParsedDiffFile[] {
const files = parseDiff(diffText);
return files.map(highlightDiffFile);
}