From ed25db4e22a0ec87d2268d5962e3e04bcad0c929 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Jan 2026 15:26:09 +0700 Subject: [PATCH] feat(git-diff): server-side syntax highlighting with full file context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package-lock.json | 8 + .../app/src/components/explorer-sidebar.tsx | 4 +- packages/app/src/components/git-diff-pane.tsx | 312 +++++------------ .../src/hooks/use-highlighted-diff-query.ts | 69 ++++ packages/app/src/lib/send-rpc-request.ts | 1 + .../app/src/utils/diff-highlighter.test.ts | 272 ++++++++++++++ packages/app/src/utils/diff-highlighter.ts | 279 +++++++++++++++ packages/server/package.json | 8 + packages/server/src/server/messages.ts | 54 +++ packages/server/src/server/session.ts | 102 ++++++ .../src/server/utils/diff-highlighter.test.ts | 252 +++++++++++++ .../src/server/utils/diff-highlighter.ts | 331 ++++++++++++++++++ .../src/server/utils/syntax-highlighter.ts | 185 ++++++++++ 13 files changed, 1653 insertions(+), 224 deletions(-) create mode 100644 packages/app/src/hooks/use-highlighted-diff-query.ts create mode 100644 packages/app/src/utils/diff-highlighter.test.ts create mode 100644 packages/app/src/utils/diff-highlighter.ts create mode 100644 packages/server/src/server/utils/diff-highlighter.test.ts create mode 100644 packages/server/src/server/utils/diff-highlighter.ts create mode 100644 packages/server/src/server/utils/syntax-highlighter.ts diff --git a/package-lock.json b/package-lock.json index 1841371dd..fdc3ab2ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21320,6 +21320,14 @@ "dependencies": { "@ai-sdk/openai": "^2.0.52", "@deepgram/sdk": "^3.4.0", + "@lezer/common": "^1.5.0", + "@lezer/css": "^1.3.0", + "@lezer/highlight": "^1.2.3", + "@lezer/html": "^1.3.13", + "@lezer/javascript": "^1.5.4", + "@lezer/json": "^1.0.3", + "@lezer/markdown": "^1.6.2", + "@lezer/python": "^1.1.18", "@modelcontextprotocol/sdk": "^1.20.1", "@openrouter/ai-sdk-provider": "^1.2.0", "ai": "^5.0.76", diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index a1527fbdb..f5e6aca05 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -381,8 +381,8 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index c482eca9a..a3bc29e7c 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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; 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 = { - 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 ( + + {line.tokens && line.type !== "header" ? ( + + ) : ( + + {line.content || " "} + + )} + + ); +} + function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) { const { theme } = useUnistyles(); const [isExpanded, setIsExpanded] = useState(defaultExpanded); @@ -254,38 +151,11 @@ function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) {isExpanded && ( - {file.lines.map((line, lineIndex) => ( - - {line.tokens && line.type !== "header" ? ( - - ) : ( - - {line.content || " "} - - )} - - ))} + {file.hunks.map((hunk, hunkIndex) => + hunk.lines.map((line, lineIndex) => ( + + )) + )} )} @@ -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 ( @@ -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) { No changes ) : ( - highlightedFiles.map((file, fileIndex) => ( + files.map((file, fileIndex) => ( )) )} @@ -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, diff --git a/packages/app/src/hooks/use-highlighted-diff-query.ts b/packages/app/src/hooks/use-highlighted-diff-query.ts new file mode 100644 index 000000000..e9cd937df --- /dev/null +++ b/packages/app/src/hooks/use-highlighted-diff-query.ts @@ -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[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, + }; +} diff --git a/packages/app/src/lib/send-rpc-request.ts b/packages/app/src/lib/send-rpc-request.ts index c84018c9e..8deeb5a49 100644 --- a/packages/app/src/lib/send-rpc-request.ts +++ b/packages/app/src/lib/send-rpc-request.ts @@ -99,6 +99,7 @@ class RpcError extends Error { */ const RESPONSE_TYPE_MAP: Record = { git_diff_request: "git_diff_response", + highlighted_diff_request: "highlighted_diff_response", file_explorer_request: "file_explorer_response", file_download_token_request: "file_download_token_response", git_repo_info_request: "git_repo_info_response", diff --git a/packages/app/src/utils/diff-highlighter.test.ts b/packages/app/src/utils/diff-highlighter.test.ts new file mode 100644 index 000000000..30fa3a816 --- /dev/null +++ b/packages/app/src/utils/diff-highlighter.test.ts @@ -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"); + }); +}); diff --git a/packages/app/src/utils/diff-highlighter.ts b/packages/app/src/utils/diff-highlighter.ts new file mode 100644 index 000000000..03b75b417 --- /dev/null +++ b/packages/app/src/utils/diff-highlighter.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 { + const lines = new Map(); + + 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 { + const lines = new Map(); + + 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): 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, + highlighted: HighlightToken[][] +): Map { + const lookup = new Map(); + + 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); +} diff --git a/packages/server/package.json b/packages/server/package.json index d65086202..bff703129 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -19,6 +19,14 @@ "dependencies": { "@ai-sdk/openai": "^2.0.52", "@deepgram/sdk": "^3.4.0", + "@lezer/common": "^1.5.0", + "@lezer/css": "^1.3.0", + "@lezer/highlight": "^1.2.3", + "@lezer/html": "^1.3.13", + "@lezer/javascript": "^1.5.4", + "@lezer/json": "^1.0.3", + "@lezer/markdown": "^1.6.2", + "@lezer/python": "^1.1.18", "@modelcontextprotocol/sdk": "^1.20.1", "@openrouter/ai-sdk-provider": "^1.2.0", "ai": "^5.0.76", diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 784e36d3e..f1644cebb 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -453,6 +453,46 @@ export const GitDiffRequestSchema = z.object({ requestId: z.string().optional(), }); +// Highlighted diff token schema +const HighlightTokenSchema = z.object({ + text: z.string(), + style: z.enum([ + "keyword", "comment", "string", "number", "literal", + "function", "definition", "class", "type", "tag", + "attribute", "property", "variable", "operator", + "punctuation", "regexp", "escape", "meta", "heading", "link", + ]).nullable(), +}); + +const DiffLineSchema = z.object({ + type: z.enum(["add", "remove", "context", "header"]), + content: z.string(), + tokens: z.array(HighlightTokenSchema).optional(), +}); + +const DiffHunkSchema = z.object({ + oldStart: z.number(), + oldCount: z.number(), + newStart: z.number(), + newCount: z.number(), + lines: z.array(DiffLineSchema), +}); + +const ParsedDiffFileSchema = z.object({ + path: z.string(), + isNew: z.boolean(), + isDeleted: z.boolean(), + additions: z.number(), + deletions: z.number(), + hunks: z.array(DiffHunkSchema), +}); + +export const HighlightedDiffRequestSchema = z.object({ + type: z.literal("highlighted_diff_request"), + agentId: z.string(), + requestId: z.string().optional(), +}); + const FileExplorerEntrySchema = z.object({ name: z.string(), path: z.string(), @@ -517,6 +557,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModeMessageSchema, AgentPermissionResponseMessageSchema, GitDiffRequestSchema, + HighlightedDiffRequestSchema, FileExplorerRequestSchema, FileDownloadTokenRequestSchema, ListPersistedAgentsRequestMessageSchema, @@ -741,6 +782,16 @@ export const GitDiffResponseSchema = z.object({ }), }); +export const HighlightedDiffResponseSchema = z.object({ + type: z.literal("highlighted_diff_response"), + payload: z.object({ + agentId: z.string(), + files: z.array(ParsedDiffFileSchema), + error: z.string().nullable(), + requestId: z.string().optional(), + }), +}); + export const FileExplorerResponseSchema = z.object({ type: z.literal("file_explorer_response"), payload: z.object({ @@ -817,6 +868,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentDeletedMessageSchema, ListPersistedAgentsResponseSchema, GitDiffResponseSchema, + HighlightedDiffResponseSchema, FileExplorerResponseSchema, FileDownloadTokenResponseSchema, GitRepoInfoResponseSchema, @@ -873,6 +925,8 @@ export type SetAgentModeMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type GitDiffRequest = z.infer; export type GitDiffResponse = z.infer; +export type HighlightedDiffRequest = z.infer; +export type HighlightedDiffResponse = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; export type FileDownloadTokenRequest = z.infer; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7aaaeb2b3..c456d51b2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -20,6 +20,7 @@ import { type FileDownloadTokenRequest, type GitSetupOptions, } from "./messages.js"; +import { parseAndHighlightDiff } from "./utils/diff-highlighter.js"; import { getSystemPrompt } from "./agent/system-prompt.js"; import { getAllTools } from "./agent/llm-openai.js"; import { TTSManager } from "./agent/tts-manager.js"; @@ -843,6 +844,10 @@ export class Session { await this.handleGitDiffRequest(msg.agentId, msg.requestId); break; + case "highlighted_diff_request": + await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId); + break; + case "file_explorer_request": await this.handleFileExplorerRequest(msg); break; @@ -2054,6 +2059,103 @@ export class Session { } } + /** + * Handle highlighted diff request - returns parsed and syntax-highlighted diff + */ + private async handleHighlightedDiffRequest( + agentId: string, + requestId?: string + ): Promise { + 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 */ diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts new file mode 100644 index 000000000..c0b61eeae --- /dev/null +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts new file mode 100644 index 000000000..82e5649e6 --- /dev/null +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -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 { + const lines = new Map(); + + 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 { + const lines = new Map(); + + 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): 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, + highlighted: HighlightToken[][] +): Map { + const lookup = new Map(); + + 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 { + 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(); + 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, + oldTokensByLine: Map +): 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 { + const files = parseDiff(diffText); + + const highlightedFiles = await Promise.all( + files.map((file) => highlightDiffWithFileContent(file, cwd)) + ); + + return highlightedFiles; +} + +// Re-export types +export type { HighlightToken }; diff --git a/packages/server/src/server/utils/syntax-highlighter.ts b/packages/server/src/server/utils/syntax-highlighter.ts new file mode 100644 index 000000000..9e365d7ad --- /dev/null +++ b/packages/server/src/server/utils/syntax-highlighter.ts @@ -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 = { + // 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 = 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; +}