Extract isomorphic @getpaseo/highlight package with syntax-highlighted file preview

- Create packages/highlight with shared Lezer-based syntax highlighting (types, parsers, highlighter, color maps)
- Add syntax highlighting and line numbers to file-pane.tsx with memoized CodeLine component
- Import shared highlight colors in git-diff-pane.tsx instead of local definitions
- Delete duplicated syntax-highlighter.ts from both app and server
- Fix TSX dialect to "ts jsx" (correct per Lezer docs)
This commit is contained in:
Mohamed Boudra
2026-03-21 19:56:49 +07:00
parent 6b50ace2bd
commit 5f065c2685
20 changed files with 1978 additions and 1376 deletions
+168 -77
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import React, { useCallback, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
ActivityIndicator,
Image as RNImage,
@@ -10,65 +10,152 @@ import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
} from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { Fonts } from '@/constants/theme'
import { useSessionStore, type ExplorerFile } from '@/stores/session-store'
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
} from '@/components/web-desktop-scrollbar'
import {
highlightCode,
darkHighlightColors,
lightHighlightColors,
type HighlightToken,
type HighlightStyle,
} from '@getpaseo/highlight'
interface CodeLineProps {
tokens: HighlightToken[]
lineNumber: number
gutterWidth: number
colorMap: Record<HighlightStyle, string>
baseColor: string
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null
isLoading: boolean
showDesktopWebScrollbar: boolean
isMobile: boolean
filePath: string
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
colorMap,
baseColor,
}: CodeLineProps) {
return (
<View style={codeLineStyles.line}>
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
</View>
<Text style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
style={{ color: token.style ? colorMap[token.style] ?? baseColor : baseColor }}
>
{token.text}
</Text>
))}
</Text>
</View>
)
})
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: 'row',
},
gutter: {
alignItems: 'flex-end',
paddingRight: theme.spacing[3],
flexShrink: 0,
},
gutterText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
opacity: 0.4,
},
lineText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
flex: 1,
},
}))
function FilePreviewBody({
preview,
isLoading,
showDesktopWebScrollbar,
isMobile,
}: {
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
}) {
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles()
const isDark = theme.colors.surface0 === '#18181c'
const colorMap = isDark ? darkHighlightColors : lightHighlightColors
const baseColor = isDark ? '#c9d1d9' : '#24292f'
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar
const previewScrollRef = useRef<RNScrollView>(null)
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics()
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== 'text') {
return null
}
return highlightCode(preview.content ?? '', filePath)
}, [preview?.kind, preview?.content, filePath])
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0
const digits = String(highlightedLines.length).length
return Math.max(digits * 8 + 12, 28)
}, [highlightedLines])
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
previewScrollbarMetrics.onScroll(event)
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
)
const handlePreviewLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onLayout(event);
previewScrollbarMetrics.onLayout(event)
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
);
)
if (isLoading && !preview) {
return (
@@ -76,7 +163,7 @@ function FilePreviewBody({
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
)
}
if (!preview) {
@@ -84,10 +171,26 @@ function FilePreviewBody({
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text>
</View>
);
)
}
if (preview.kind === "text") {
if (preview.kind === 'text') {
const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]]
const codeLines = (
<View>
{lines.map((tokens, index) => (
<CodeLine
key={index}
tokens={tokens}
lineNumber={index + 1}
gutterWidth={gutterWidth}
colorMap={colorMap}
baseColor={baseColor}
/>
))}
</View>
)
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -96,17 +199,13 @@ function FilePreviewBody({
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>
<Text style={styles.codeText}>{preview.content}</Text>
</View>
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
@@ -114,7 +213,7 @@ function FilePreviewBody({
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
@@ -122,14 +221,14 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}}
/>
</View>
);
)
}
if (preview.kind === "image" && preview.content) {
if (preview.kind === 'image' && preview.content) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -139,16 +238,14 @@ function FilePreviewBody({
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
uri: `data:${preview.mimeType ?? 'image/png'};base64,${preview.content}`,
}}
style={styles.previewImage}
resizeMode="contain"
@@ -158,11 +255,11 @@ function FilePreviewBody({
enabled={enablePreviewDesktopScrollbar}
metrics={previewScrollbarMetrics}
onScrollToOffset={(nextOffset) => {
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}}
/>
</View>
);
)
}
return (
@@ -170,7 +267,7 @@ function FilePreviewBody({
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
)
}
export function FilePane({
@@ -178,34 +275,33 @@ export function FilePane({
workspaceRoot,
filePath,
}: {
serverId: string;
workspaceRoot: string;
filePath: string;
serverId: string
workspaceRoot: string
filePath: string
}) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot])
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath])
const query = useQuery({
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
queryKey: ['workspaceFile', serverId, normalizedWorkspaceRoot, normalizedFilePath],
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
queryFn: async () => {
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
return { file: null as ExplorerFile | null, error: "Host is not connected" };
return { file: null as ExplorerFile | null, error: 'Host is not connected' }
}
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedFilePath,
"file"
);
return { file: payload.file ?? null, error: payload.error ?? null };
'file'
)
return { file: payload.file ?? null, error: payload.error ?? null }
},
staleTime: 5_000,
});
})
return (
<View style={styles.container} testID="workspace-file-pane">
@@ -220,9 +316,10 @@ export function FilePane({
isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
filePath={filePath}
/>
</View>
);
)
}
const styles = StyleSheet.create((theme) => ({
@@ -233,8 +330,8 @@ const styles = StyleSheet.create((theme) => ({
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing[4],
},
loadingText: {
@@ -245,12 +342,12 @@ const styles = StyleSheet.create((theme) => ({
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
textAlign: "center",
textAlign: 'center',
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
textAlign: 'center',
},
binaryMetaText: {
marginTop: theme.spacing[2],
@@ -268,20 +365,14 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
codeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
color: theme.colors.foreground,
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
},
previewImage: {
width: "100%",
width: '100%',
height: 420,
},
}));
}))
File diff suppressed because it is too large Load Diff
+118 -128
View File
@@ -1,31 +1,27 @@
import {
highlightCode,
isLanguageSupported,
type HighlightToken,
} from "./syntax-highlighter";
import { highlightCode, isLanguageSupported, type HighlightToken } from '@getpaseo/highlight'
export interface DiffLine {
type: "add" | "remove" | "context" | "header";
content: string;
lineNumber?: number; // Line number in the original/new file
tokens?: HighlightToken[];
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[];
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[];
path: string
isNew: boolean
isDeleted: boolean
additions: number
deletions: number
hunks: DiffHunk[]
}
/**
@@ -33,93 +29,87 @@ export interface ParsedDiffFile {
*/
export function parseDiff(diffText: string): ParsedDiffFile[] {
if (!diffText || diffText.trim().length === 0) {
return [];
return []
}
const files: ParsedDiffFile[] = [];
const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
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];
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");
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\//);
let path = 'unknown'
const pathMatch = firstLine.match(/a\/(.*?) b\//)
if (pathMatch) {
path = pathMatch[1];
path = pathMatch[1]
} else {
const newFileMatch = firstLine.match(/b\/(.+)$/);
const newFileMatch = firstLine.match(/b\/(.+)$/)
if (newFileMatch) {
path = newFileMatch[1];
path = newFileMatch[1]
}
}
const hunks: DiffHunk[] = [];
let currentHunk: DiffHunk | null = null;
let additions = 0;
let deletions = 0;
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];
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;
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+))? @@/
);
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
if (hunkMatch) {
if (currentHunk) {
hunks.push(currentHunk);
hunks.push(currentHunk)
}
currentHunk = {
oldStart: parseInt(hunkMatch[1], 10),
oldCount: parseInt(hunkMatch[2] ?? "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;
newCount: parseInt(hunkMatch[4] ?? '1', 10),
lines: [{ type: 'header', content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }],
}
continue
}
if (!currentHunk) 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("\\")) {
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 });
currentHunk.lines.push({ type: 'context', content: line })
}
}
if (currentHunk) {
hunks.push(currentHunk);
hunks.push(currentHunk)
}
files.push({ path, isNew, isDeleted, additions, deletions, hunks });
files.push({ path, isNew, isDeleted, additions, deletions, hunks })
}
return files;
return files
}
/**
@@ -127,23 +117,23 @@ export function parseDiff(diffText: string): ParsedDiffFile[] {
* Returns a map of new line numbers to their content.
*/
export function reconstructNewFile(hunks: DiffHunk[]): Map<number, string> {
const lines = new Map<number, string>();
const lines = new Map<number, string>()
for (const hunk of hunks) {
let newLineNum = hunk.newStart;
let newLineNum = hunk.newStart
for (const line of hunk.lines) {
if (line.type === "header") continue;
if (line.type === 'header') continue
if (line.type === "add" || line.type === "context") {
lines.set(newLineNum, line.content);
newLineNum++;
if (line.type === 'add' || line.type === 'context') {
lines.set(newLineNum, line.content)
newLineNum++
}
// Remove lines don't appear in the new file
}
}
return lines;
return lines
}
/**
@@ -151,23 +141,23 @@ export function reconstructNewFile(hunks: DiffHunk[]): Map<number, string> {
* Returns a map of old line numbers to their content.
*/
export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
const lines = new Map<number, string>();
const lines = new Map<number, string>()
for (const hunk of hunks) {
let oldLineNum = hunk.oldStart;
let oldLineNum = hunk.oldStart
for (const line of hunk.lines) {
if (line.type === "header") continue;
if (line.type === 'header') continue
if (line.type === "remove" || line.type === "context") {
lines.set(oldLineNum, line.content);
oldLineNum++;
if (line.type === 'remove' || line.type === 'context') {
lines.set(oldLineNum, line.content)
oldLineNum++
}
// Add lines don't appear in the old file
}
}
return lines;
return lines
}
/**
@@ -180,100 +170,100 @@ export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
*/
export function highlightDiffFile(file: ParsedDiffFile): ParsedDiffFile {
if (!isLanguageSupported(file.path)) {
return file;
return file
}
// Reconstruct both versions
const newFileLines = reconstructNewFile(file.hunks);
const oldFileLines = reconstructOldFile(file.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);
const newFileContent = buildFileContent(newFileLines)
const oldFileContent = buildFileContent(oldFileLines)
// Highlight both versions
const newHighlighted = highlightCode(newFileContent, file.path);
const oldHighlighted = highlightCode(oldFileContent, file.path);
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);
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;
let oldLineNum = hunk.oldStart
let newLineNum = hunk.newStart
const highlightedLines = hunk.lines.map((line): DiffLine => {
if (line.type === "header") {
return line;
if (line.type === 'header') {
return line
}
let tokens: HighlightToken[] | undefined;
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") {
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++;
tokens = newTokensByLine.get(newLineNum)
oldLineNum++
newLineNum++
}
return tokens ? { ...line, tokens } : line;
});
return tokens ? { ...line, tokens } : line
})
return { ...hunk, lines: highlightedLines };
});
return { ...hunk, lines: highlightedLines }
})
return { ...file, hunks: highlightedHunks };
return { ...file, hunks: highlightedHunks }
}
function buildFileContent(lineMap: Map<number, string>): string {
if (lineMap.size === 0) return "";
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 lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b)
const minLine = lineNumbers[0]
const maxLine = lineNumbers[lineNumbers.length - 1]
const lines: string[] = [];
const lines: string[] = []
for (let i = minLine; i <= maxLine; i++) {
lines.push(lineMap.get(i) ?? "");
lines.push(lineMap.get(i) ?? '')
}
return lines.join("\n");
return lines.join('\n')
}
function buildTokenLookup(
lineMap: Map<number, string>,
highlighted: HighlightToken[][]
): Map<number, HighlightToken[]> {
const lookup = new Map<number, HighlightToken[]>();
const lookup = new Map<number, HighlightToken[]>()
if (lineMap.size === 0) return lookup;
if (lineMap.size === 0) return lookup
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b);
const minLine = lineNumbers[0];
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;
const lineNum = minLine + i
if (lineMap.has(lineNum)) {
lookup.set(lineNum, highlighted[i]);
lookup.set(lineNum, highlighted[i])
}
}
return lookup;
return lookup
}
/**
* Parse and highlight a complete diff
*/
export function parseAndHighlightDiff(diffText: string): ParsedDiffFile[] {
const files = parseDiff(diffText);
return files.map(highlightDiffFile);
const files = parseDiff(diffText)
return files.map(highlightDiffFile)
}
@@ -1,219 +0,0 @@
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 cppParser } from "@lezer/cpp";
import { parser as goParser } from "@lezer/go";
import { parser as htmlParser } from "@lezer/html";
import { parser as javaParser } from "@lezer/java";
import { parser as pythonParser } from "@lezer/python";
import { parser as markdownParser } from "@lezer/markdown";
import { parser as phpParser } from "@lezer/php";
import { parser as rustParser } from "@lezer/rust";
import { parser as xmlParser } from "@lezer/xml";
import { parser as yamlParser } from "@lezer/yaml";
import { parser as elixirParser } from "lezer-elixir";
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: "tsx jsx" }),
mjs: jsParser,
cjs: jsParser,
// C / C++ / Objective-C
c: cppParser,
h: cppParser,
cc: cppParser,
cpp: cppParser,
cxx: cppParser,
hpp: cppParser,
hxx: cppParser,
m: cppParser,
mm: cppParser,
// JSON
json: jsonParser,
// CSS
css: cssParser,
scss: cssParser,
// HTML
html: htmlParser,
htm: htmlParser,
// XML
xml: xmlParser,
// Java
java: javaParser,
// Python
py: pythonParser,
// Go
go: goParser,
// PHP
php: phpParser,
// YAML
yaml: yamlParser,
yml: yamlParser,
// Rust
rs: rustParser,
// Elixir
ex: elixirParser,
exs: elixirParser,
// 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;
}