From a187f26e1b9bc0c9f02a4e48e1d49df0eba85bcf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Jan 2026 10:07:08 +0700 Subject: [PATCH] feat(git-diff): add Lezer-based syntax highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @lezer/highlight and language parsers (JS/TS, JSON, CSS, HTML, Python, Markdown) - Create syntax-highlighter utility that parses code with Lezer and returns highlight tokens - Integrate highlighting into diff view with color-coded tokens - Keywords (purple), strings (green), numbers (orange), functions (blue), types (yellow), etc. - Highlighting respects add/remove line backgrounds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 97 +++++++++ packages/app/package.json | 8 + packages/app/src/components/git-diff-pane.tsx | 175 +++++++++++++++-- packages/app/src/utils/syntax-highlighter.ts | 185 ++++++++++++++++++ 4 files changed, 445 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/utils/syntax-highlighter.ts diff --git a/package-lock.json b/package-lock.json index 4b1f72104..1841371dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5139,6 +5139,95 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz", + "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.6.tgz", + "integrity": "sha512-u42yGuGBsHgodm86lwi0HAtUTNSs23yl9RoaI5em90B+OGm9/XuWkNiJ46sKkCgp8Tp4zgoBQbepcshfKLhFdw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.2.tgz", + "integrity": "sha512-iNSdKrIK0FfOjVPVpV0fu7OykdncYpEzf4vkG9szFf60ql/ObZShoVbM9u1tgkogDOmubms1CyoNS2/unOXWNw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", @@ -21149,6 +21238,14 @@ "@boudra/expo-two-way-audio": "^0.1.3", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.2.6", + "@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", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", diff --git a/packages/app/package.json b/packages/app/package.json index d32b6ba22..c72661510 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -18,6 +18,14 @@ "@boudra/expo-two-way-audio": "^0.1.3", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.2.6", + "@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", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index e282573b9..c482eca9a 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,20 +1,29 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } 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, + type HighlightToken, + type HighlightStyle, +} from "@/utils/syntax-highlighter"; + +interface DiffLine { + type: "add" | "remove" | "context" | "header"; + content: string; + tokens?: HighlightToken[]; +} interface ParsedDiffFile { path: string; isNew: boolean; additions: number; deletions: number; - lines: Array<{ - type: "add" | "remove" | "context" | "header"; - content: string; - }>; + lines: DiffLine[]; } function parseDiff(diffText: string): ParsedDiffFile[] { @@ -45,7 +54,7 @@ function parseDiff(diffText: string): ParsedDiffFile[] { } } - const parsedLines: ParsedDiffFile["lines"] = []; + const parsedLines: DiffLine[] = []; let additions = 0; let deletions = 0; @@ -82,6 +91,119 @@ function parseDiff(diffText: string): ParsedDiffFile[] { 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 { + tokens: HighlightToken[]; + baseStyle: HighlightStyle | null; + lineType: "add" | "remove" | "context" | "header"; +} + +function HighlightedText({ tokens, lineType }: HighlightedTextProps) { + const { theme } = useUnistyles(); + + // Get color for a highlight style, respecting the line type + 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; + + if (!style) return baseColor; + + // Define highlight colors - these work on both light and dark backgrounds + 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], + 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], + }; + + return highlightColors[style] ?? baseColor; + }; + + return ( + + {tokens.map((token, index) => ( + + {token.text} + + ))} + + ); +} + interface DiffFileSectionProps { file: ParsedDiffFile; defaultExpanded?: boolean; @@ -143,17 +265,25 @@ function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) line.type === "context" && styles.contextLineContainer, ]} > - - {line.content || " "} - + {line.tokens && line.type !== "header" ? ( + + ) : ( + + {line.content || " "} + + )} ))} @@ -178,6 +308,12 @@ 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 ( @@ -186,8 +322,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { ); } - const parsedFiles = isError || !diff ? [] : parseDiff(diff); - const hasChanges = parsedFiles.length > 0; + const hasChanges = highlightedFiles.length > 0; const errorMessage = isError && error instanceof Error ? error.message : null; return ( @@ -217,7 +352,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { No changes ) : ( - parsedFiles.map((file, fileIndex) => ( + highlightedFiles.map((file, fileIndex) => ( )) )} diff --git a/packages/app/src/utils/syntax-highlighter.ts b/packages/app/src/utils/syntax-highlighter.ts new file mode 100644 index 000000000..698015e35 --- /dev/null +++ b/packages/app/src/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: "tsx 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; +}