AgentGraph / frontend /src /components /shared /MonacoEditorWithHighlights.tsx
wu981526092's picture
🚀 Deploy AgentGraph: Complete agent monitoring and knowledge graph system
c2ea5ed
import React, { useRef, useState, useEffect, useMemo } from "react";
import { Editor } from "@monaco-editor/react";
import type { editor } from "monaco-editor";
import { Button } from "@/components/ui/button";
import { ChevronUp, ChevronDown } from "lucide-react";
// Add interface for range - export for reuse
export interface Range {
start: number;
end: number;
}
interface LineMapping {
originalLineNumber: number;
startChar: number;
endChar: number;
content: string;
}
interface MonacoEditorWithHighlightsProps {
numberedLines: string[]; // each already prefixed with <L#>
highlightRanges: Range[];
showNavigationControls?: boolean;
height?: string;
theme?: string;
className?: string;
}
export const MonacoEditorWithHighlights: React.FC<
MonacoEditorWithHighlightsProps
> = ({
numberedLines,
highlightRanges,
showNavigationControls = true,
height = "100%",
theme = "vs-dark",
className = "",
}) => {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(0);
const [decorations, setDecorations] = useState<string[]>([]);
// Parse numbered lines and reconstruct original content
const { reconstructedContent, characterRanges } = useMemo(() => {
const mapping: LineMapping[] = [];
// Parse each numbered line and extract line numbers
const parsedLines: {
lineNumber: number;
content: string;
index: number;
}[] = [];
for (let i = 0; i < numberedLines.length; i++) {
const line = numberedLines[i];
if (!line) continue;
const match = line.match(/^<L(\d+)>\s*(.*)$/);
if (!match) continue;
const [, lineNumber, content] = match;
if (!lineNumber || content === undefined) continue;
parsedLines.push({
lineNumber: parseInt(lineNumber),
content,
index: i,
});
}
// Sort by line number to ensure proper order
parsedLines.sort((a, b) => a.lineNumber - b.lineNumber);
// Simple concatenation - just join all content together
let rawContent = "";
for (let i = 0; i < parsedLines.length; i++) {
const currentLine = parsedLines[i];
if (!currentLine) continue;
// Track character mapping for highlighting
const startChar = rawContent.length;
rawContent += currentLine.content;
const endChar = rawContent.length;
mapping.push({
originalLineNumber: currentLine.lineNumber,
startChar,
endChar,
content: currentLine.content,
});
}
// Process the raw content for better formatting
let processedContent = rawContent;
// Convert literal \\n to actual line breaks
processedContent = processedContent.replace(/\\n/g, "\n");
// Convert literal \\t to actual tabs
processedContent = processedContent.replace(/\\t/g, "\t");
// Try to detect and format JSON content (handle multiple JSON objects)
const jsonRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
const jsonMatches = processedContent.match(jsonRegex);
if (jsonMatches) {
jsonMatches.forEach((jsonStr) => {
try {
const parsed = JSON.parse(jsonStr);
const formatted = JSON.stringify(parsed, null, 2);
processedContent = processedContent.replace(jsonStr, formatted);
} catch (e) {
// If JSON parsing fails, keep original content
}
});
}
// Clean up excessive whitespace but preserve intentional formatting
processedContent = processedContent
.replace(/[ \t]+/g, " ") // Multiple spaces/tabs to single space
.replace(/\n\s*\n\s*\n/g, "\n\n") // Multiple newlines to double newline max
.trim(); // Remove leading/trailing whitespace
// Update character mapping based on content transformation
const contentLengthDiff = processedContent.length - rawContent.length;
// Convert line-based ranges to character-based ranges
const charRanges = highlightRanges
.map((range) => {
const startMapping = mapping.find(
(m) => m.originalLineNumber === range.start + 1
);
const endMapping = mapping.find(
(m) => m.originalLineNumber === range.end + 1
);
if (!startMapping || !endMapping) {
return { startChar: 0, endChar: 0 };
}
// Adjust for content transformation
let adjustedStartChar = startMapping.startChar;
let adjustedEndChar = endMapping.endChar;
// Simple adjustment - this might need refinement for complex cases
if (contentLengthDiff > 0) {
const adjustmentRatio = processedContent.length / rawContent.length;
adjustedStartChar = Math.floor(
startMapping.startChar * adjustmentRatio
);
adjustedEndChar = Math.floor(endMapping.endChar * adjustmentRatio);
}
return {
startChar: adjustedStartChar,
endChar: adjustedEndChar,
};
})
.filter((range) => range.startChar !== range.endChar);
return {
reconstructedContent: processedContent,
characterRanges: charRanges,
};
}, [numberedLines, highlightRanges]);
// Sort ranges by start position for navigation
const sortedRanges = [...characterRanges].sort(
(a, b) => a.startChar - b.startChar
);
// Scroll to specific highlighted range in Monaco Editor
const scrollToHighlight = (rangeIndex: number) => {
if (!editorRef.current || sortedRanges.length === 0) return;
const range = sortedRanges[rangeIndex];
if (!range) return;
try {
const model = editorRef.current.getModel();
if (!model) {
// If model isn't ready, retry after a short delay
setTimeout(() => scrollToHighlight(rangeIndex), 100);
return;
}
const startPosition = model.getPositionAt(range.startChar);
// Scroll to the position with animation
editorRef.current.revealPositionInCenter(startPosition, 1); // 1 = animated
// Update decorations to highlight current range
updateHighlightDecorations();
} catch (error) {
console.warn("Error scrolling to highlight:", error);
// Retry once after a delay
setTimeout(() => {
try {
const startPosition = editorRef
.current!.getModel()!
.getPositionAt(range.startChar);
editorRef.current!.revealPositionInCenter(startPosition, 1);
updateHighlightDecorations();
} catch (retryError) {
console.warn("Retry failed for scrolling to highlight:", retryError);
}
}, 200);
}
};
// Navigation handlers
const goToNextHighlight = () => {
if (sortedRanges.length === 0) return;
const nextIndex = (currentHighlightIndex + 1) % sortedRanges.length;
setCurrentHighlightIndex(nextIndex);
scrollToHighlight(nextIndex);
};
const goToPreviousHighlight = () => {
if (sortedRanges.length === 0) return;
const prevIndex =
currentHighlightIndex === 0
? sortedRanges.length - 1
: currentHighlightIndex - 1;
setCurrentHighlightIndex(prevIndex);
scrollToHighlight(prevIndex);
};
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return; // Don't interfere with input fields
}
if (e.key === ">" || (e.key === "ArrowDown" && e.ctrlKey)) {
e.preventDefault();
goToNextHighlight();
} else if (e.key === "<" || (e.key === "ArrowUp" && e.ctrlKey)) {
e.preventDefault();
goToPreviousHighlight();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentHighlightIndex, sortedRanges.length]);
// Scroll first highlighted range into view on initial load or when ranges change
useEffect(() => {
if (sortedRanges.length > 0) {
setCurrentHighlightIndex(0);
// Add a small delay to ensure Monaco editor is fully rendered
const timeoutId = setTimeout(() => {
scrollToHighlight(0);
}, 150);
return () => clearTimeout(timeoutId);
}
// Return undefined when no cleanup is needed
return undefined;
}, [highlightRanges]);
// Update decorations when current highlight changes
useEffect(() => {
updateHighlightDecorations();
}, [currentHighlightIndex, characterRanges]);
// Handle Monaco Editor mount
const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor) => {
editorRef.current = editor;
// Apply initial decorations for highlights
updateHighlightDecorations();
// Configure editor options
editor.updateOptions({
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
lineNumbers: "on",
folding: false,
renderWhitespace: "selection",
fontSize: 12,
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
lineHeight: 18,
letterSpacing: 0.5,
fontWeight: "400",
renderLineHighlight: "none",
cursorStyle: "line",
cursorBlinking: "blink",
smoothScrolling: true,
});
};
// Update highlight decorations in Monaco Editor
const updateHighlightDecorations = () => {
if (!editorRef.current) return;
// Clear all existing decorations first to prevent old highlights from persisting
if (decorations.length > 0) {
editorRef.current.deltaDecorations(decorations, []);
setDecorations([]);
}
const newDecorations: editor.IModelDeltaDecoration[] = [];
characterRanges.forEach((range, index) => {
const startPosition = editorRef
.current!.getModel()!
.getPositionAt(range.startChar);
const endPosition = editorRef
.current!.getModel()!
.getPositionAt(range.endChar);
const isCurrentHighlight = index === currentHighlightIndex;
newDecorations.push({
range: {
startLineNumber: startPosition.lineNumber,
startColumn: startPosition.column,
endLineNumber: endPosition.lineNumber,
endColumn: endPosition.column,
},
options: {
className: isCurrentHighlight
? "current-highlight-decoration"
: "highlight-decoration",
isWholeLine: false,
},
});
});
// Apply new decorations
const decorationIds = editorRef.current.deltaDecorations(
[],
newDecorations
);
setDecorations(decorationIds);
};
// Detect language for syntax highlighting
const detectLanguage = (content: string): string => {
// Check for JSON content
if (content.trim().startsWith("{") && content.trim().endsWith("}")) {
try {
JSON.parse(content);
return "json";
} catch {
// Not valid JSON, continue checking
}
}
// Check for Python code patterns
if (/^(import|from|def|class|if __name__|print\()/m.test(content)) {
return "python";
}
// Check for JavaScript/TypeScript patterns
if (/^(import|export|function|const|let|var|console\.log)/m.test(content)) {
return "javascript";
}
// Check for shell/bash patterns
if (
/^(#!\/bin\/|cd |ls |mkdir |rm |pip install|npm install)/m.test(content)
) {
return "shell";
}
// Check for SQL patterns
if (/^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/im.test(content)) {
return "sql";
}
// Default to plain text
return "text";
};
const detectedLanguage = useMemo(
() => detectLanguage(reconstructedContent),
[reconstructedContent]
);
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Navigation controls */}
{showNavigationControls && sortedRanges.length > 0 && (
<div className="flex items-center justify-center gap-2 p-2 border-b bg-muted/10">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={goToPreviousHighlight}
title="Previous highlight (<)"
>
<ChevronUp className="h-3 w-3" />
</Button>
<span className="text-xs text-muted-foreground">
{currentHighlightIndex + 1} / {sortedRanges.length}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={goToNextHighlight}
title="Next highlight (>)"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
)}
{/* Monaco Editor */}
<div className="flex-1">
<Editor
height={height}
defaultLanguage={detectedLanguage}
value={reconstructedContent}
onMount={handleEditorDidMount}
theme={theme}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
lineNumbers: "on",
folding: false,
renderWhitespace: "selection",
fontSize: 12,
fontFamily:
"'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
padding: { top: 10, bottom: 10 },
}}
/>
</div>
</div>
);
};