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
@@ -381,8 +381,8 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
+90 -222
View File
@@ -1,149 +1,17 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import { useSessionStore } from "@/stores/session-store";
import { useGitDiffQuery } from "@/hooks/use-git-diff-query";
import {
highlightCode,
isLanguageSupported,
useHighlightedDiffQuery,
type ParsedDiffFile,
type DiffLine,
type HighlightToken,
type HighlightStyle,
} from "@/utils/syntax-highlighter";
} from "@/hooks/use-highlighted-diff-query";
interface DiffLine {
type: "add" | "remove" | "context" | "header";
content: string;
tokens?: HighlightToken[];
}
interface ParsedDiffFile {
path: string;
isNew: boolean;
additions: number;
deletions: number;
lines: DiffLine[];
}
function parseDiff(diffText: string): ParsedDiffFile[] {
if (!diffText || diffText.trim().length === 0) {
return [];
}
const files: ParsedDiffFile[] = [];
const sections = diffText.split(/^diff --git /m).filter(Boolean);
for (const section of sections) {
const lines = section.split("\n");
const firstLine = lines[0];
// Check for new file indicator
const isNew = section.includes("new file mode") || section.includes("/dev/null");
// Extract path - handle both regular and new file formats
let path = "unknown";
const pathMatch = firstLine.match(/a\/(.*?) b\//);
if (pathMatch) {
path = pathMatch[1];
} else {
// For new files from /dev/null, extract from b/...
const newFileMatch = firstLine.match(/b\/(.+)$/);
if (newFileMatch) {
path = newFileMatch[1];
}
}
const parsedLines: DiffLine[] = [];
let additions = 0;
let deletions = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip metadata lines
if (i === 0) continue;
if (line.startsWith("index ")) continue;
if (line.startsWith("--- ")) continue;
if (line.startsWith("+++ ")) continue;
if (line.startsWith("new file mode")) continue;
if (line.startsWith("@@")) {
const hunkMatch = line.match(/^(@@ .+? @@)/);
const hunkHeader = hunkMatch ? hunkMatch[1] : line;
parsedLines.push({ type: "header", content: hunkHeader });
} else if (line.startsWith("+")) {
parsedLines.push({ type: "add", content: line.slice(1) });
additions++;
} else if (line.startsWith("-")) {
parsedLines.push({ type: "remove", content: line.slice(1) });
deletions++;
} else if (line.startsWith(" ")) {
parsedLines.push({ type: "context", content: line.slice(1) });
} else if (line.length > 0) {
parsedLines.push({ type: "context", content: line });
}
}
files.push({ path, isNew, additions, deletions, lines: parsedLines });
}
return files;
}
function applyHighlighting(files: ParsedDiffFile[]): ParsedDiffFile[] {
return files.map((file) => {
if (!isLanguageSupported(file.path)) {
return file;
}
// Collect all non-header lines to build the "file content" for highlighting
// We need to build separate content for add/context and remove/context
// to properly highlight each side of the diff
const addContextLines: Array<{ index: number; content: string }> = [];
const removeLines: Array<{ index: number; content: string }> = [];
file.lines.forEach((line, index) => {
if (line.type === "add" || line.type === "context") {
addContextLines.push({ index, content: line.content });
}
if (line.type === "remove") {
removeLines.push({ index, content: line.content });
}
});
// Highlight the "new" file content (additions + context)
const addContextCode = addContextLines.map((l) => l.content).join("\n");
const addContextHighlighted = highlightCode(addContextCode, file.path);
// Highlight the "old" file content (removals only, context already covered)
const removeCode = removeLines.map((l) => l.content).join("\n");
const removeHighlighted = highlightCode(removeCode, file.path);
// Map highlighted tokens back to diff lines
const newLines = [...file.lines];
addContextLines.forEach((item, highlightIndex) => {
if (addContextHighlighted[highlightIndex]) {
newLines[item.index] = {
...newLines[item.index],
tokens: addContextHighlighted[highlightIndex],
};
}
});
removeLines.forEach((item, highlightIndex) => {
if (removeHighlighted[highlightIndex]) {
newLines[item.index] = {
...newLines[item.index],
tokens: removeHighlighted[highlightIndex],
};
}
});
return { ...file, lines: newLines };
});
}
type HighlightStyle = NonNullable<HighlightToken["style"]>;
interface HighlightedTextProps {
tokens: HighlightToken[];
@@ -154,40 +22,35 @@ interface HighlightedTextProps {
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
// Get color for a highlight style, respecting the line type
// Get color for a highlight style using GitHub Dark theme
// Text colors are the same regardless of line type - only background changes
const getTokenColor = (style: HighlightStyle | null): string => {
// For add/remove lines, use appropriate base colors
const baseColor =
lineType === "add"
? theme.colors.palette.green[200]
: lineType === "remove"
? theme.colors.palette.red[200]
: theme.colors.mutedForeground;
const baseColor = "#c9d1d9"; // GitHub foreground
if (!style) return baseColor;
// Define highlight colors - these work on both light and dark backgrounds
// GitHub Dark theme colors
const highlightColors: Record<HighlightStyle, string> = {
keyword: theme.colors.palette.purple[500],
comment: theme.colors.mutedForeground,
string: theme.colors.palette.green[400],
number: theme.colors.palette.orange[500],
literal: theme.colors.palette.orange[500],
function: theme.colors.palette.blue[400],
definition: theme.colors.palette.blue[400],
class: theme.colors.palette.yellow[400],
type: theme.colors.palette.yellow[400],
tag: theme.colors.palette.red[500],
attribute: theme.colors.palette.purple[500],
property: theme.colors.palette.blue[400],
keyword: "#ff7b72", // red
comment: "#8b949e", // gray
string: "#a5d6ff", // light blue
number: "#79c0ff", // blue
literal: "#79c0ff", // blue
function: "#d2a8ff", // purple
definition: "#d2a8ff", // purple
class: "#ffa657", // orange
type: "#ff7b72", // red (same as keyword in GitHub)
tag: "#7ee787", // green
attribute: "#79c0ff", // blue
property: "#79c0ff", // blue
variable: baseColor,
operator: baseColor,
punctuation: baseColor,
regexp: theme.colors.palette.green[400],
escape: theme.colors.palette.orange[500],
meta: theme.colors.mutedForeground,
heading: theme.colors.palette.blue[400],
link: theme.colors.palette.blue[400],
operator: "#79c0ff", // blue
punctuation: "#c9d1d9", // foreground
regexp: "#a5d6ff", // light blue
escape: "#79c0ff", // blue
meta: "#8b949e", // gray
heading: "#79c0ff", // blue
link: "#a5d6ff", // light blue
};
return highlightColors[style] ?? baseColor;
@@ -209,6 +72,40 @@ interface DiffFileSectionProps {
defaultExpanded?: boolean;
}
function DiffLineView({ line }: { line: DiffLine }) {
return (
<View
style={[
styles.diffLineContainer,
line.type === "add" && styles.addLineContainer,
line.type === "remove" && styles.removeLineContainer,
line.type === "header" && styles.headerLineContainer,
line.type === "context" && styles.contextLineContainer,
]}
>
{line.tokens && line.type !== "header" ? (
<HighlightedText
tokens={line.tokens}
baseStyle={null}
lineType={line.type}
/>
) : (
<Text
style={[
styles.diffLineText,
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
]}
>
{line.content || " "}
</Text>
)}
</View>
);
}
function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) {
const { theme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
@@ -254,38 +151,11 @@ function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps)
</Pressable>
{isExpanded && (
<View style={styles.diffContent}>
{file.lines.map((line, lineIndex) => (
<View
key={lineIndex}
style={[
styles.diffLineContainer,
line.type === "add" && styles.addLineContainer,
line.type === "remove" && styles.removeLineContainer,
line.type === "header" && styles.headerLineContainer,
line.type === "context" && styles.contextLineContainer,
]}
>
{line.tokens && line.type !== "header" ? (
<HighlightedText
tokens={line.tokens}
baseStyle={null}
lineType={line.type}
/>
) : (
<Text
style={[
styles.diffLineText,
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
]}
>
{line.content || " "}
</Text>
)}
</View>
))}
{file.hunks.map((hunk, hunkIndex) =>
hunk.lines.map((line, lineIndex) => (
<DiffLineView key={`${hunkIndex}-${lineIndex}`} line={line} />
))
)}
</View>
)}
</View>
@@ -299,7 +169,7 @@ interface GitDiffPaneProps {
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const { diff, isLoading, isFetching, isError, error, refresh } = useGitDiffQuery({
const { files, isLoading, isFetching, isError, error, refresh } = useHighlightedDiffQuery({
serverId,
agentId,
});
@@ -308,12 +178,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
state.sessions[serverId]?.agents?.get(agentId)
);
const highlightedFiles = useMemo(() => {
if (isError || !diff) return [];
const parsed = parseDiff(diff);
return applyHighlighting(parsed);
}, [diff, isError]);
if (!agent) {
return (
<View style={styles.errorContainer}>
@@ -322,7 +186,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
);
}
const hasChanges = highlightedFiles.length > 0;
const hasChanges = files.length > 0;
const errorMessage = isError && error instanceof Error ? error.message : null;
return (
@@ -352,7 +216,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
<Text style={styles.emptyText}>No changes</Text>
</View>
) : (
highlightedFiles.map((file, fileIndex) => (
files.map((file, fileIndex) => (
<DiffFileSection key={fileIndex} file={file} />
))
)}
@@ -365,9 +229,9 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
contentContainer: {
padding: theme.spacing[4],
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[8],
gap: theme.spacing[3],
},
loadingContainer: {
flex: 1,
@@ -405,15 +269,18 @@ const styles = StyleSheet.create((theme) => ({
fileSection: {
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
backgroundColor: theme.colors.card,
backgroundColor: theme.colors.muted,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
marginBottom: theme.spacing[2],
},
fileHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
paddingVertical: theme.spacing[4],
gap: theme.spacing[3],
padding: theme.spacing[2],
paddingVertical: theme.spacing[2],
gap: theme.spacing[2],
},
fileHeaderPressed: {
opacity: 0.7,
@@ -445,7 +312,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
newBadge: {
backgroundColor: theme.colors.palette.green[800],
backgroundColor: "rgba(46, 160, 67, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
@@ -453,8 +320,8 @@ const styles = StyleSheet.create((theme) => ({
},
newBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.palette.green[200],
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
additions: {
fontSize: theme.fontSize.sm,
@@ -471,6 +338,7 @@ const styles = StyleSheet.create((theme) => ({
diffContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
backgroundColor: "#0d1117", // GitHub dark background
},
diffLineContainer: {
paddingHorizontal: theme.spacing[3],
@@ -482,25 +350,25 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
addLineContainer: {
backgroundColor: theme.colors.palette.green[900],
backgroundColor: "rgba(46, 160, 67, 0.1)", // GitHub green with transparency
},
addLineText: {
color: theme.colors.palette.green[200],
color: "#c9d1d9", // Same text color as all code
},
removeLineContainer: {
backgroundColor: theme.colors.palette.red[900],
backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red with transparency
},
removeLineText: {
color: theme.colors.palette.red[200],
color: "#c9d1d9", // Same text color as all code
},
headerLineContainer: {
backgroundColor: theme.colors.muted,
backgroundColor: "#161b22", // GitHub dark header
},
headerLineText: {
color: theme.colors.mutedForeground,
},
contextLineContainer: {
backgroundColor: theme.colors.card,
backgroundColor: "#0d1117", // GitHub dark background
},
contextLineText: {
color: theme.colors.mutedForeground,
@@ -0,0 +1,69 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import { useSessionStore } from "@/stores/session-store";
import { sendRpcRequest } from "@/lib/send-rpc-request";
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
import type { HighlightedDiffResponse } from "@server/server/messages";
const HIGHLIGHTED_DIFF_STALE_TIME = 30_000;
function highlightedDiffQueryKey(serverId: string, agentId: string) {
return ["highlightedDiff", serverId, agentId] as const;
}
interface UseHighlightedDiffQueryOptions {
serverId: string;
agentId: string;
}
export type ParsedDiffFile = HighlightedDiffResponse["payload"]["files"][number];
export type DiffHunk = ParsedDiffFile["hunks"][number];
export type DiffLine = DiffHunk["lines"][number];
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
export function useHighlightedDiffQuery({ serverId, agentId }: UseHighlightedDiffQueryOptions) {
const queryClient = useQueryClient();
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
const { isOpen, activeTab } = useExplorerSidebarStore();
const query = useQuery({
queryKey: highlightedDiffQueryKey(serverId, agentId),
queryFn: async () => {
if (!ws) {
throw new Error("WebSocket not available");
}
const response = await sendRpcRequest(ws, {
type: "highlighted_diff_request",
agentId,
});
return response.files;
},
enabled: !!ws && ws.isConnected && !!agentId,
staleTime: HIGHLIGHTED_DIFF_STALE_TIME,
refetchInterval: 10_000,
});
// Revalidate when sidebar opens with "changes" tab active
useEffect(() => {
if (!isOpen || activeTab !== "changes" || !agentId) {
return;
}
// Invalidate to trigger background refetch (shows stale data while fetching)
queryClient.invalidateQueries({
queryKey: highlightedDiffQueryKey(serverId, agentId),
});
}, [isOpen, activeTab, serverId, agentId, queryClient]);
const refresh = useCallback(() => {
return query.refetch();
}, [query]);
return {
files: query.data ?? [],
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refresh,
};
}
+1
View File
@@ -99,6 +99,7 @@ class RpcError extends Error {
*/
const RESPONSE_TYPE_MAP: Record<string, SessionOutboundMessage["type"]> = {
git_diff_request: "git_diff_response",
highlighted_diff_request: "highlighted_diff_response",
file_explorer_request: "file_explorer_response",
file_download_token_request: "file_download_token_response",
git_repo_info_request: "git_repo_info_response",
@@ -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);
}