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:
co-authored by
Claude Opus 4.5
parent
a187f26e1b
commit
ed25db4e22
Generated
+8
@@ -21320,6 +21320,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^2.0.52",
|
"@ai-sdk/openai": "^2.0.52",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@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",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.0",
|
"@openrouter/ai-sdk-provider": "^1.2.0",
|
||||||
"ai": "^5.0.76",
|
"ai": "^5.0.76",
|
||||||
|
|||||||
@@ -381,8 +381,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[1],
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native";
|
||||||
import { ScrollView } from "react-native-gesture-handler";
|
import { ScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronRight } from "lucide-react-native";
|
import { ChevronRight } from "lucide-react-native";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useGitDiffQuery } from "@/hooks/use-git-diff-query";
|
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
useHighlightedDiffQuery,
|
||||||
isLanguageSupported,
|
type ParsedDiffFile,
|
||||||
|
type DiffLine,
|
||||||
type HighlightToken,
|
type HighlightToken,
|
||||||
type HighlightStyle,
|
} from "@/hooks/use-highlighted-diff-query";
|
||||||
} from "@/utils/syntax-highlighter";
|
|
||||||
|
|
||||||
interface DiffLine {
|
type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||||
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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HighlightedTextProps {
|
interface HighlightedTextProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -154,40 +22,35 @@ interface HighlightedTextProps {
|
|||||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||||
const { theme } = useUnistyles();
|
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 => {
|
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||||
// For add/remove lines, use appropriate base colors
|
const baseColor = "#c9d1d9"; // GitHub foreground
|
||||||
const baseColor =
|
|
||||||
lineType === "add"
|
|
||||||
? theme.colors.palette.green[200]
|
|
||||||
: lineType === "remove"
|
|
||||||
? theme.colors.palette.red[200]
|
|
||||||
: theme.colors.mutedForeground;
|
|
||||||
|
|
||||||
if (!style) return baseColor;
|
if (!style) return baseColor;
|
||||||
|
|
||||||
// Define highlight colors - these work on both light and dark backgrounds
|
// GitHub Dark theme colors
|
||||||
const highlightColors: Record<HighlightStyle, string> = {
|
const highlightColors: Record<HighlightStyle, string> = {
|
||||||
keyword: theme.colors.palette.purple[500],
|
keyword: "#ff7b72", // red
|
||||||
comment: theme.colors.mutedForeground,
|
comment: "#8b949e", // gray
|
||||||
string: theme.colors.palette.green[400],
|
string: "#a5d6ff", // light blue
|
||||||
number: theme.colors.palette.orange[500],
|
number: "#79c0ff", // blue
|
||||||
literal: theme.colors.palette.orange[500],
|
literal: "#79c0ff", // blue
|
||||||
function: theme.colors.palette.blue[400],
|
function: "#d2a8ff", // purple
|
||||||
definition: theme.colors.palette.blue[400],
|
definition: "#d2a8ff", // purple
|
||||||
class: theme.colors.palette.yellow[400],
|
class: "#ffa657", // orange
|
||||||
type: theme.colors.palette.yellow[400],
|
type: "#ff7b72", // red (same as keyword in GitHub)
|
||||||
tag: theme.colors.palette.red[500],
|
tag: "#7ee787", // green
|
||||||
attribute: theme.colors.palette.purple[500],
|
attribute: "#79c0ff", // blue
|
||||||
property: theme.colors.palette.blue[400],
|
property: "#79c0ff", // blue
|
||||||
variable: baseColor,
|
variable: baseColor,
|
||||||
operator: baseColor,
|
operator: "#79c0ff", // blue
|
||||||
punctuation: baseColor,
|
punctuation: "#c9d1d9", // foreground
|
||||||
regexp: theme.colors.palette.green[400],
|
regexp: "#a5d6ff", // light blue
|
||||||
escape: theme.colors.palette.orange[500],
|
escape: "#79c0ff", // blue
|
||||||
meta: theme.colors.mutedForeground,
|
meta: "#8b949e", // gray
|
||||||
heading: theme.colors.palette.blue[400],
|
heading: "#79c0ff", // blue
|
||||||
link: theme.colors.palette.blue[400],
|
link: "#a5d6ff", // light blue
|
||||||
};
|
};
|
||||||
|
|
||||||
return highlightColors[style] ?? baseColor;
|
return highlightColors[style] ?? baseColor;
|
||||||
@@ -209,6 +72,40 @@ interface DiffFileSectionProps {
|
|||||||
defaultExpanded?: boolean;
|
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) {
|
function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
@@ -254,38 +151,11 @@ function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps)
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<View style={styles.diffContent}>
|
<View style={styles.diffContent}>
|
||||||
{file.lines.map((line, lineIndex) => (
|
{file.hunks.map((hunk, hunkIndex) =>
|
||||||
<View
|
hunk.lines.map((line, lineIndex) => (
|
||||||
key={lineIndex}
|
<DiffLineView key={`${hunkIndex}-${lineIndex}`} line={line} />
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -299,7 +169,7 @@ interface GitDiffPaneProps {
|
|||||||
|
|
||||||
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { diff, isLoading, isFetching, isError, error, refresh } = useGitDiffQuery({
|
const { files, isLoading, isFetching, isError, error, refresh } = useHighlightedDiffQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
});
|
});
|
||||||
@@ -308,12 +178,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
state.sessions[serverId]?.agents?.get(agentId)
|
state.sessions[serverId]?.agents?.get(agentId)
|
||||||
);
|
);
|
||||||
|
|
||||||
const highlightedFiles = useMemo(() => {
|
|
||||||
if (isError || !diff) return [];
|
|
||||||
const parsed = parseDiff(diff);
|
|
||||||
return applyHighlighting(parsed);
|
|
||||||
}, [diff, isError]);
|
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.errorContainer}>
|
<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;
|
const errorMessage = isError && error instanceof Error ? error.message : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -352,7 +216,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
<Text style={styles.emptyText}>No changes</Text>
|
<Text style={styles.emptyText}>No changes</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
highlightedFiles.map((file, fileIndex) => (
|
files.map((file, fileIndex) => (
|
||||||
<DiffFileSection key={fileIndex} file={file} />
|
<DiffFileSection key={fileIndex} file={file} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -365,9 +229,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
padding: theme.spacing[4],
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[8],
|
paddingBottom: theme.spacing[8],
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -405,15 +269,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fileSection: {
|
fileSection: {
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.muted,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
fileHeader: {
|
fileHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: theme.spacing[3],
|
padding: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[2],
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
fileHeaderPressed: {
|
fileHeaderPressed: {
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
@@ -445,7 +312,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
newBadge: {
|
newBadge: {
|
||||||
backgroundColor: theme.colors.palette.green[800],
|
backgroundColor: "rgba(46, 160, 67, 0.2)",
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
@@ -453,8 +320,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
newBadgeText: {
|
newBadgeText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.green[200],
|
color: theme.colors.palette.green[400],
|
||||||
},
|
},
|
||||||
additions: {
|
additions: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -471,6 +338,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffContent: {
|
diffContent: {
|
||||||
borderTopWidth: theme.borderWidth[1],
|
borderTopWidth: theme.borderWidth[1],
|
||||||
borderTopColor: theme.colors.border,
|
borderTopColor: theme.colors.border,
|
||||||
|
backgroundColor: "#0d1117", // GitHub dark background
|
||||||
},
|
},
|
||||||
diffLineContainer: {
|
diffLineContainer: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
@@ -482,25 +350,25 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
addLineContainer: {
|
addLineContainer: {
|
||||||
backgroundColor: theme.colors.palette.green[900],
|
backgroundColor: "rgba(46, 160, 67, 0.1)", // GitHub green with transparency
|
||||||
},
|
},
|
||||||
addLineText: {
|
addLineText: {
|
||||||
color: theme.colors.palette.green[200],
|
color: "#c9d1d9", // Same text color as all code
|
||||||
},
|
},
|
||||||
removeLineContainer: {
|
removeLineContainer: {
|
||||||
backgroundColor: theme.colors.palette.red[900],
|
backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red with transparency
|
||||||
},
|
},
|
||||||
removeLineText: {
|
removeLineText: {
|
||||||
color: theme.colors.palette.red[200],
|
color: "#c9d1d9", // Same text color as all code
|
||||||
},
|
},
|
||||||
headerLineContainer: {
|
headerLineContainer: {
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: "#161b22", // GitHub dark header
|
||||||
},
|
},
|
||||||
headerLineText: {
|
headerLineText: {
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
},
|
},
|
||||||
contextLineContainer: {
|
contextLineContainer: {
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: "#0d1117", // GitHub dark background
|
||||||
},
|
},
|
||||||
contextLineText: {
|
contextLineText: {
|
||||||
color: theme.colors.mutedForeground,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -99,6 +99,7 @@ class RpcError extends Error {
|
|||||||
*/
|
*/
|
||||||
const RESPONSE_TYPE_MAP: Record<string, SessionOutboundMessage["type"]> = {
|
const RESPONSE_TYPE_MAP: Record<string, SessionOutboundMessage["type"]> = {
|
||||||
git_diff_request: "git_diff_response",
|
git_diff_request: "git_diff_response",
|
||||||
|
highlighted_diff_request: "highlighted_diff_response",
|
||||||
file_explorer_request: "file_explorer_response",
|
file_explorer_request: "file_explorer_response",
|
||||||
file_download_token_request: "file_download_token_response",
|
file_download_token_request: "file_download_token_response",
|
||||||
git_repo_info_request: "git_repo_info_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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^2.0.52",
|
"@ai-sdk/openai": "^2.0.52",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@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",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.0",
|
"@openrouter/ai-sdk-provider": "^1.2.0",
|
||||||
"ai": "^5.0.76",
|
"ai": "^5.0.76",
|
||||||
|
|||||||
@@ -453,6 +453,46 @@ export const GitDiffRequestSchema = z.object({
|
|||||||
requestId: z.string().optional(),
|
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({
|
const FileExplorerEntrySchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
@@ -517,6 +557,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModeMessageSchema,
|
SetAgentModeMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
GitDiffRequestSchema,
|
GitDiffRequestSchema,
|
||||||
|
HighlightedDiffRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
ListPersistedAgentsRequestMessageSchema,
|
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({
|
export const FileExplorerResponseSchema = z.object({
|
||||||
type: z.literal("file_explorer_response"),
|
type: z.literal("file_explorer_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -817,6 +868,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentDeletedMessageSchema,
|
AgentDeletedMessageSchema,
|
||||||
ListPersistedAgentsResponseSchema,
|
ListPersistedAgentsResponseSchema,
|
||||||
GitDiffResponseSchema,
|
GitDiffResponseSchema,
|
||||||
|
HighlightedDiffResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
GitRepoInfoResponseSchema,
|
GitRepoInfoResponseSchema,
|
||||||
@@ -873,6 +925,8 @@ export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
|||||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
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 FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||||
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type FileDownloadTokenRequest,
|
type FileDownloadTokenRequest,
|
||||||
type GitSetupOptions,
|
type GitSetupOptions,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
|
import { parseAndHighlightDiff } from "./utils/diff-highlighter.js";
|
||||||
import { getSystemPrompt } from "./agent/system-prompt.js";
|
import { getSystemPrompt } from "./agent/system-prompt.js";
|
||||||
import { getAllTools } from "./agent/llm-openai.js";
|
import { getAllTools } from "./agent/llm-openai.js";
|
||||||
import { TTSManager } from "./agent/tts-manager.js";
|
import { TTSManager } from "./agent/tts-manager.js";
|
||||||
@@ -843,6 +844,10 @@ export class Session {
|
|||||||
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "highlighted_diff_request":
|
||||||
|
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
|
||||||
|
break;
|
||||||
|
|
||||||
case "file_explorer_request":
|
case "file_explorer_request":
|
||||||
await this.handleFileExplorerRequest(msg);
|
await this.handleFileExplorerRequest(msg);
|
||||||
break;
|
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
|
* Handle read-only file explorer requests scoped to an agent's cwd
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user