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