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
parent a187f26e1b
commit ed25db4e22
13 changed files with 1653 additions and 224 deletions

8
package-lock.json generated
View File

@@ -21320,6 +21320,14 @@
"dependencies": {
"@ai-sdk/openai": "^2.0.52",
"@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/python": "^1.1.18",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0",
"ai": "^5.0.76",

View File

@@ -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,
},

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,

View File

@@ -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,
};
}

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",

View File

@@ -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");
});
});

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);
}

View File

@@ -19,6 +19,14 @@
"dependencies": {
"@ai-sdk/openai": "^2.0.52",
"@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/python": "^1.1.18",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0",
"ai": "^5.0.76",

View File

@@ -453,6 +453,46 @@ export const GitDiffRequestSchema = z.object({
requestId: z.string().optional(),
});
// Highlighted diff token schema
const HighlightTokenSchema = z.object({
text: z.string(),
style: z.enum([
"keyword", "comment", "string", "number", "literal",
"function", "definition", "class", "type", "tag",
"attribute", "property", "variable", "operator",
"punctuation", "regexp", "escape", "meta", "heading", "link",
]).nullable(),
});
const DiffLineSchema = z.object({
type: z.enum(["add", "remove", "context", "header"]),
content: z.string(),
tokens: z.array(HighlightTokenSchema).optional(),
});
const DiffHunkSchema = z.object({
oldStart: z.number(),
oldCount: z.number(),
newStart: z.number(),
newCount: z.number(),
lines: z.array(DiffLineSchema),
});
const ParsedDiffFileSchema = z.object({
path: z.string(),
isNew: z.boolean(),
isDeleted: z.boolean(),
additions: z.number(),
deletions: z.number(),
hunks: z.array(DiffHunkSchema),
});
export const HighlightedDiffRequestSchema = z.object({
type: z.literal("highlighted_diff_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
const FileExplorerEntrySchema = z.object({
name: z.string(),
path: z.string(),
@@ -517,6 +557,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
HighlightedDiffRequestSchema,
FileExplorerRequestSchema,
FileDownloadTokenRequestSchema,
ListPersistedAgentsRequestMessageSchema,
@@ -741,6 +782,16 @@ export const GitDiffResponseSchema = z.object({
}),
});
export const HighlightedDiffResponseSchema = z.object({
type: z.literal("highlighted_diff_response"),
payload: z.object({
agentId: z.string(),
files: z.array(ParsedDiffFileSchema),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
export const FileExplorerResponseSchema = z.object({
type: z.literal("file_explorer_response"),
payload: z.object({
@@ -817,6 +868,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentDeletedMessageSchema,
ListPersistedAgentsResponseSchema,
GitDiffResponseSchema,
HighlightedDiffResponseSchema,
FileExplorerResponseSchema,
FileDownloadTokenResponseSchema,
GitRepoInfoResponseSchema,
@@ -873,6 +925,8 @@ export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;

View File

@@ -20,6 +20,7 @@ import {
type FileDownloadTokenRequest,
type GitSetupOptions,
} from "./messages.js";
import { parseAndHighlightDiff } from "./utils/diff-highlighter.js";
import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js";
import { TTSManager } from "./agent/tts-manager.js";
@@ -843,6 +844,10 @@ export class Session {
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
break;
case "highlighted_diff_request":
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
@@ -2054,6 +2059,103 @@ export class Session {
}
}
/**
* Handle highlighted diff request - returns parsed and syntax-highlighted diff
*/
private async handleHighlightedDiffRequest(
agentId: string,
requestId?: string
): Promise<void> {
console.log(
`[Session ${this.clientId}] Handling highlighted diff request for agent ${agentId}`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: [],
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
// Get diff for tracked files
const { stdout: trackedDiff } = await execAsync("git diff HEAD", {
cwd: agent.cwd,
});
// Get diff for untracked files (new files not yet added to git)
let untrackedDiff = "";
try {
const { stdout: untrackedFiles } = await execAsync(
"git ls-files --others --exclude-standard",
{ cwd: agent.cwd }
);
const newFiles = untrackedFiles.trim().split("\n").filter(Boolean);
for (const file of newFiles) {
try {
const { stdout: fileDiff } = await execAsync(
`git diff --no-index /dev/null "${file}" || true`,
{ cwd: agent.cwd }
);
if (fileDiff) {
untrackedDiff += fileDiff;
}
} catch {
// Ignore errors for individual files
}
}
} catch {
// Ignore errors getting untracked files
}
const combinedDiff = trackedDiff + untrackedDiff;
// Parse and highlight the diff
const highlightedFiles = await parseAndHighlightDiff(
combinedDiff,
agent.cwd
);
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: highlightedFiles,
error: null,
requestId,
},
});
console.log(
`[Session ${this.clientId}] Highlighted diff for agent ${agentId} completed (${highlightedFiles.length} files)`
);
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to get highlighted diff for agent ${agentId}:`,
error
);
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
files: [],
error: error.message,
requestId,
},
});
}
}
/**
* Handle read-only file explorer requests scoped to an agent's cwd
*/

View File

@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import {
parseDiff,
reconstructNewFile,
reconstructOldFile,
highlightDiffFromHunks,
type ParsedDiffFile,
} from "./diff-highlighter.js";
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 + content lines
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("highlightDiffFromHunks", () => {
it("adds syntax highlighting tokens to TypeScript code", () => {
const files = parseDiff(SIMPLE_DIFF);
const highlighted = highlightDiffFromHunks(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 = highlightDiffFromHunks(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 = highlightDiffFromHunks(file);
// Lines should not have tokens
expect(highlighted.hunks[0].lines[1].tokens).toBeUndefined();
expect(highlighted.hunks[0].lines[2].tokens).toBeUndefined();
});
});

View File

@@ -0,0 +1,331 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
highlightCode,
isLanguageSupported,
type HighlightToken,
} from "./syntax-highlighter.js";
export interface DiffLine {
type: "add" | "remove" | "context" | "header";
content: string;
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++;
}
}
}
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++;
}
}
}
return lines;
}
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];
for (let i = 0; i < highlighted.length; i++) {
const lineNum = minLine + i;
if (lineMap.has(lineNum)) {
lookup.set(lineNum, highlighted[i]);
}
}
return lookup;
}
/**
* Apply syntax highlighting to diff hunks using reconstructed file content.
* This is the fallback when actual file content is not available.
*/
export function highlightDiffFromHunks(file: ParsedDiffFile): ParsedDiffFile {
if (!isLanguageSupported(file.path)) {
return file;
}
// Reconstruct both versions from hunks
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);
return applyTokensToHunks(file, newTokensByLine, oldTokensByLine);
}
/**
* Apply syntax highlighting to diff hunks using actual file content.
* This provides better context for the parser.
*/
export async function highlightDiffWithFileContent(
file: ParsedDiffFile,
cwd: string
): Promise<ParsedDiffFile> {
if (!isLanguageSupported(file.path)) {
return file;
}
const filePath = resolve(cwd, file.path);
try {
// Read the current file content (the "new" version)
const fileContent = await readFile(filePath, "utf-8");
// Highlight the entire file
const highlighted = highlightCode(fileContent, file.path);
// Build lookup: line number (1-indexed) -> tokens
const tokensByLine = new Map<number, HighlightToken[]>();
for (let i = 0; i < highlighted.length; i++) {
tokensByLine.set(i + 1, highlighted[i]);
}
// For removed lines, we need to reconstruct from hunks since they're not in the file
const oldFileLines = reconstructOldFile(file.hunks);
const oldFileContent = buildFileContent(oldFileLines);
const oldHighlighted = highlightCode(oldFileContent, file.path);
const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted);
return applyTokensToHunks(file, tokensByLine, oldTokensByLine);
} catch {
// If file read fails (deleted file, etc.), fall back to hunk-based highlighting
return highlightDiffFromHunks(file);
}
}
function applyTokensToHunks(
file: ParsedDiffFile,
newTokensByLine: Map<number, HighlightToken[]>,
oldTokensByLine: Map<number, HighlightToken[]>
): ParsedDiffFile {
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 };
}
/**
* Parse and highlight a complete diff, using actual file content when available.
*/
export async function parseAndHighlightDiff(
diffText: string,
cwd: string
): Promise<ParsedDiffFile[]> {
const files = parseDiff(diffText);
const highlightedFiles = await Promise.all(
files.map((file) => highlightDiffWithFileContent(file, cwd))
);
return highlightedFiles;
}
// Re-export types
export type { HighlightToken };

View File

@@ -0,0 +1,185 @@
import { highlightTree, tagHighlighter, tags } from "@lezer/highlight";
import { parser as jsParser } from "@lezer/javascript";
import { parser as jsonParser } from "@lezer/json";
import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as pythonParser } from "@lezer/python";
import { parser as markdownParser } from "@lezer/markdown";
import type { Parser } from "@lezer/common";
// Map file extensions to parsers
const parsersByExtension: Record<string, Parser> = {
// JavaScript/TypeScript
js: jsParser,
jsx: jsParser.configure({ dialect: "jsx" }),
ts: jsParser.configure({ dialect: "ts" }),
tsx: jsParser.configure({ dialect: "ts jsx" }),
mjs: jsParser,
cjs: jsParser,
// JSON
json: jsonParser,
// CSS
css: cssParser,
scss: cssParser,
// HTML
html: htmlParser,
htm: htmlParser,
// Python
py: pythonParser,
// Markdown
md: markdownParser,
mdx: markdownParser,
};
export type HighlightStyle =
| "keyword"
| "comment"
| "string"
| "number"
| "literal"
| "function"
| "definition"
| "class"
| "type"
| "tag"
| "attribute"
| "property"
| "variable"
| "operator"
| "punctuation"
| "regexp"
| "escape"
| "meta"
| "heading"
| "link";
export interface HighlightToken {
text: string;
style: HighlightStyle | null;
}
// Create highlighter using tagHighlighter
const highlighter = tagHighlighter([
{ tag: tags.keyword, class: "keyword" },
{ tag: tags.controlKeyword, class: "keyword" },
{ tag: tags.operatorKeyword, class: "keyword" },
{ tag: tags.definitionKeyword, class: "keyword" },
{ tag: tags.moduleKeyword, class: "keyword" },
{ tag: tags.comment, class: "comment" },
{ tag: tags.lineComment, class: "comment" },
{ tag: tags.blockComment, class: "comment" },
{ tag: tags.docComment, class: "comment" },
{ tag: tags.string, class: "string" },
{ tag: tags.special(tags.string), class: "string" },
{ tag: tags.number, class: "number" },
{ tag: tags.integer, class: "number" },
{ tag: tags.float, class: "number" },
{ tag: tags.bool, class: "literal" },
{ tag: tags.null, class: "literal" },
{ tag: tags.function(tags.variableName), class: "function" },
{ tag: tags.function(tags.propertyName), class: "function" },
{ tag: tags.definition(tags.variableName), class: "definition" },
{ tag: tags.definition(tags.propertyName), class: "definition" },
{ tag: tags.definition(tags.function(tags.variableName)), class: "definition" },
{ tag: tags.className, class: "class" },
{ tag: tags.definition(tags.className), class: "class" },
{ tag: tags.typeName, class: "type" },
{ tag: tags.tagName, class: "tag" },
{ tag: tags.attributeName, class: "attribute" },
{ tag: tags.attributeValue, class: "string" },
{ tag: tags.propertyName, class: "property" },
{ tag: tags.variableName, class: "variable" },
{ tag: tags.local(tags.variableName), class: "variable" },
{ tag: tags.special(tags.variableName), class: "variable" },
{ tag: tags.operator, class: "operator" },
{ tag: tags.punctuation, class: "punctuation" },
{ tag: tags.bracket, class: "punctuation" },
{ tag: tags.separator, class: "punctuation" },
{ tag: tags.regexp, class: "regexp" },
{ tag: tags.escape, class: "escape" },
{ tag: tags.meta, class: "meta" },
{ tag: tags.heading, class: "heading" },
{ tag: tags.link, class: "link" },
{ tag: tags.url, class: "link" },
]);
function getParserForFile(filename: string): Parser | null {
const ext = filename.split(".").pop()?.toLowerCase();
if (!ext) return null;
return parsersByExtension[ext] ?? null;
}
export function highlightCode(code: string, filename: string): HighlightToken[][] {
const parser = getParserForFile(filename);
if (!parser) {
// No parser available, return unhighlighted lines
return code.split("\n").map((line) => [{ text: line, style: null }]);
}
const tree = parser.parse(code);
const lines = code.split("\n");
const result: HighlightToken[][] = [];
// Initialize with unhighlighted content
for (let i = 0; i < lines.length; i++) {
result.push([]);
}
// Build a map of character positions to styles
const styleMap: Array<HighlightStyle | null> = new Array(code.length).fill(null);
// Use highlightTree to populate the style map
highlightTree(tree, highlighter, (from, to, classes) => {
for (let i = from; i < to && i < styleMap.length; i++) {
styleMap[i] = classes as HighlightStyle;
}
});
// Convert style map to tokens per line
let pos = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
if (line.length === 0) {
result[lineIndex].push({ text: "", style: null });
pos++; // skip newline
continue;
}
let currentToken: HighlightToken = { text: "", style: styleMap[pos] };
for (let i = 0; i < line.length; i++) {
const charStyle = styleMap[pos + i];
if (charStyle === currentToken.style) {
currentToken.text += line[i];
} else {
if (currentToken.text) {
result[lineIndex].push(currentToken);
}
currentToken = { text: line[i], style: charStyle };
}
}
if (currentToken.text) {
result[lineIndex].push(currentToken);
}
pos += line.length + 1; // +1 for newline
}
return result;
}
export function highlightLine(line: string, filename: string): HighlightToken[] {
const result = highlightCode(line, filename);
return result[0] ?? [{ text: line, style: null }];
}
export function getSupportedExtensions(): string[] {
return Object.keys(parsersByExtension);
}
export function isLanguageSupported(filename: string): boolean {
return getParserForFile(filename) !== null;
}