Files
sim/apps/sim/components/emcn/components/code/code.tsx
Emir Karabeg 5c02d46d55 feat(terminal): structured output (#3026)
* feat(code): collapsed JSON in terminal

* improvement(code): addressed comments

* feat(terminal): added structured output; improvement(preview): note block

* feat(terminal): log view

* improvement(terminal): ui/ux

* improvement(terminal): default sizing and collapsed width

* fix: code colors, terminal large output handling

* fix(terminal): structured search

* improvement: preivew accuracy, invite-modal admin, logs live
2026-01-28 14:40:43 -08:00

1250 lines
37 KiB
TypeScript

'use client'
import {
Fragment,
memo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { ChevronRight } from 'lucide-react'
import { highlight, languages } from 'prismjs'
import { List, type RowComponentProps, useDynamicRowHeight, useListRef } from 'react-window'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import { cn } from '@/lib/core/utils/cn'
import './code.css'
/**
* Re-export Prism.js highlighting utilities for use across the app.
* Components can import these instead of importing from prismjs directly.
*/
export { highlight, languages }
/**
* Code editor configuration and constants.
* All code editors in the app should use these values for consistency.
*/
export const CODE_LINE_HEIGHT_PX = 21
/**
* Gutter width values based on the number of digits in line numbers.
* Provides consistent spacing across all code editors.
*/
const GUTTER_WIDTHS = [20, 20, 30, 38, 46, 54] as const
/**
* Width of the collapse column in pixels.
*/
const COLLAPSE_COLUMN_WIDTH = 12
/**
* Calculates the dynamic gutter width based on the number of lines.
* @param lineCount - The total number of lines in the code
* @returns The gutter width in pixels
*/
export function calculateGutterWidth(lineCount: number): number {
const digits = String(lineCount).length
return GUTTER_WIDTHS[Math.min(digits - 1, GUTTER_WIDTHS.length - 1)]
}
/**
* Information about a collapsible region in code.
*/
interface CollapsibleRegion {
/** Line index where the region starts (0-based) */
startLine: number
/** Line index where the region ends (0-based, inclusive) */
endLine: number
/** Type of collapsible region */
type: 'block' | 'string'
}
/**
* Minimum string length to be considered collapsible.
*/
const MIN_COLLAPSIBLE_STRING_LENGTH = 80
/**
* Maximum length of truncated string preview when collapsed.
*/
const MAX_TRUNCATED_STRING_LENGTH = 30
/**
* Regex to match a JSON string value (key: "value" pattern).
* Pre-compiled for performance.
*/
const STRING_VALUE_REGEX = /:\s*"([^"\\]|\\.)*"[,]?\s*$/
/**
* Finds collapsible regions in JSON code by matching braces and detecting long strings.
* A region is collapsible if it spans multiple lines OR contains a long string value.
* Properly handles braces inside JSON strings by tracking string boundaries with correct
* escape sequence handling (counts consecutive backslashes to determine if quotes are escaped).
*
* @param lines - Array of code lines
* @returns Map of start line index to CollapsibleRegion
*/
function findCollapsibleRegions(lines: string[]): Map<number, CollapsibleRegion> {
const regions = new Map<number, CollapsibleRegion>()
const stringRegions = new Map<number, CollapsibleRegion>()
const stack: { char: '{' | '['; line: number }[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Detect collapsible string values (long strings on a single line)
const stringMatch = line.match(STRING_VALUE_REGEX)
if (stringMatch) {
const colonIdx = line.indexOf('":')
if (colonIdx !== -1) {
const valueStart = line.indexOf('"', colonIdx + 1)
const valueEnd = line.lastIndexOf('"')
if (valueStart !== -1 && valueEnd > valueStart) {
const stringValue = line.slice(valueStart + 1, valueEnd)
if (stringValue.length >= MIN_COLLAPSIBLE_STRING_LENGTH || stringValue.includes('\\n')) {
// Store separately to avoid conflicts with block regions
stringRegions.set(i, { startLine: i, endLine: i, type: 'string' })
}
}
}
}
// Check for block regions, skipping characters inside strings
let inString = false
for (let j = 0; j < line.length; j++) {
const char = line[j]
// Toggle string state on unescaped quotes
// Must count consecutive backslashes: odd = escaped quote, even = unescaped quote
if (char === '"') {
let backslashCount = 0
let k = j - 1
while (k >= 0 && line[k] === '\\') {
backslashCount++
k--
}
// Only toggle if quote is not escaped (even number of preceding backslashes)
if (backslashCount % 2 === 0) {
inString = !inString
}
continue
}
// Skip braces inside strings
if (inString) continue
if (char === '{' || char === '[') {
stack.push({ char, line: i })
} else if (char === '}' || char === ']') {
const expected = char === '}' ? '{' : '['
if (stack.length > 0 && stack[stack.length - 1].char === expected) {
const start = stack.pop()!
if (i > start.line) {
regions.set(start.line, {
startLine: start.line,
endLine: i,
type: 'block',
})
}
}
}
}
}
// Merge string regions only where no block region exists (block takes priority)
for (const [lineIdx, region] of stringRegions) {
if (!regions.has(lineIdx)) {
regions.set(lineIdx, region)
}
}
return regions
}
/**
* Computes visible line indices based on collapsed regions.
* Only block regions hide lines; string regions just truncate content.
*
* @param totalLines - Total number of lines
* @param collapsedLines - Set of line indices that are collapsed (start lines of regions)
* @param regions - Map of collapsible regions
* @returns Sorted array of visible line indices
*/
function computeVisibleLineIndices(
totalLines: number,
collapsedLines: Set<number>,
regions: Map<number, CollapsibleRegion>
): number[] {
if (collapsedLines.size === 0) {
return Array.from({ length: totalLines }, (_, i) => i)
}
// Build sorted list of hidden ranges (only for block regions, not string regions)
const hiddenRanges: Array<{ start: number; end: number }> = []
for (const startLine of collapsedLines) {
const region = regions.get(startLine)
if (region && region.type === 'block' && region.endLine > region.startLine + 1) {
hiddenRanges.push({ start: region.startLine + 1, end: region.endLine - 1 })
}
}
hiddenRanges.sort((a, b) => a.start - b.start)
// Merge overlapping ranges
const merged: Array<{ start: number; end: number }> = []
for (const range of hiddenRanges) {
if (merged.length === 0 || merged[merged.length - 1].end < range.start - 1) {
merged.push(range)
} else {
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, range.end)
}
}
// Build visible indices by skipping hidden ranges
const visible: number[] = []
let rangeIdx = 0
for (let i = 0; i < totalLines; i++) {
while (rangeIdx < merged.length && merged[rangeIdx].end < i) {
rangeIdx++
}
if (rangeIdx < merged.length && i >= merged[rangeIdx].start && i <= merged[rangeIdx].end) {
continue
}
visible.push(i)
}
return visible
}
/**
* Truncates a long string value in a JSON line for collapsed display.
*
* @param line - The original line content
* @returns Truncated line with ellipsis
*/
function truncateStringLine(line: string): string {
const colonIdx = line.indexOf('":')
if (colonIdx === -1) return line
const valueStart = line.indexOf('"', colonIdx + 1)
if (valueStart === -1) return line
const prefix = line.slice(0, valueStart + 1)
const suffix = line.charCodeAt(line.length - 1) === 44 /* ',' */ ? '",' : '"'
const truncated = line.slice(valueStart + 1, valueStart + 1 + MAX_TRUNCATED_STRING_LENGTH)
return `${prefix}${truncated}...${suffix}`
}
/**
* Custom hook for managing JSON collapse state and computations.
*
* @param lines - Array of code lines
* @param showCollapseColumn - Whether collapse functionality is enabled
* @param language - Programming language for syntax detection
* @returns Object containing collapse state and handlers
*/
function useJsonCollapse(
lines: string[],
showCollapseColumn: boolean,
language: string
): {
collapsedLines: Set<number>
collapsibleLines: Set<number>
collapsibleRegions: Map<number, CollapsibleRegion>
collapsedStringLines: Set<number>
visibleLineIndices: number[]
toggleCollapse: (lineIndex: number) => void
} {
const [collapsedLines, setCollapsedLines] = useState<Set<number>>(new Set())
const collapsibleRegions = useMemo(() => {
if (!showCollapseColumn || language !== 'json') return new Map<number, CollapsibleRegion>()
return findCollapsibleRegions(lines)
}, [lines, showCollapseColumn, language])
const collapsibleLines = useMemo(() => new Set(collapsibleRegions.keys()), [collapsibleRegions])
// Track which collapsed lines are string type (need truncation, not hiding)
const collapsedStringLines = useMemo(() => {
const stringLines = new Set<number>()
for (const lineIdx of collapsedLines) {
const region = collapsibleRegions.get(lineIdx)
if (region?.type === 'string') {
stringLines.add(lineIdx)
}
}
return stringLines
}, [collapsedLines, collapsibleRegions])
const visibleLineIndices = useMemo(() => {
if (!showCollapseColumn) {
return Array.from({ length: lines.length }, (_, i) => i)
}
return computeVisibleLineIndices(lines.length, collapsedLines, collapsibleRegions)
}, [lines.length, collapsedLines, collapsibleRegions, showCollapseColumn])
const toggleCollapse = useCallback((lineIndex: number) => {
setCollapsedLines((prev) => {
const next = new Set(prev)
if (next.has(lineIndex)) {
next.delete(lineIndex)
} else {
next.add(lineIndex)
}
return next
})
}, [])
return {
collapsedLines,
collapsibleLines,
collapsibleRegions,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
}
}
/**
* Props for the CollapseButton component.
*/
interface CollapseButtonProps {
/** Whether the region is currently collapsed */
isCollapsed: boolean
/** Handler for toggle click */
onClick: () => void
}
/**
* Collapse/expand button with chevron icon.
* Rotates chevron based on collapse state.
*/
const CollapseButton = memo(function CollapseButton({ isCollapsed, onClick }: CollapseButtonProps) {
return (
<button
type='button'
onClick={onClick}
className='flex h-[21px] w-[12px] cursor-pointer items-center justify-center border-none bg-transparent p-0 text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
>
<ChevronRight
className={cn(
'!h-[12px] !w-[12px] transition-transform duration-100',
!isCollapsed && 'rotate-90'
)}
/>
</button>
)
})
/**
* Props for the Code.Container component.
*/
interface CodeContainerProps {
/** Editor content wrapped by this container */
children: ReactNode
/** Additional CSS classes for the container */
className?: string
/** Inline styles for the container */
style?: React.CSSProperties
/** Drag and drop handler */
onDragOver?: (e: React.DragEvent) => void
/** Drop handler */
onDrop?: (e: React.DragEvent) => void
}
/**
* Code editor container that provides consistent styling across all editors.
* Handles container chrome (border, radius, bg, font) with Tailwind.
*
* @example
* ```tsx
* <Code.Container>
* <Code.Content>
* <Editor {...props} />
* </Code.Content>
* </Code.Container>
* ```
*/
function Container({ children, className, style, onDragOver, onDrop }: CodeContainerProps) {
return (
<div
className={cn(
// Base container styling
'group relative min-h-[100px] rounded-[4px] border border-[var(--border-1)]',
'bg-[var(--surface-1)] font-medium font-mono text-sm transition-colors',
'dark:bg-[#1F1F1F]',
// Overflow handling for long content
'overflow-x-auto overflow-y-auto',
className
)}
style={style}
onDragOver={onDragOver}
onDrop={onDrop}
>
{children}
</div>
)
}
/**
* Props for Code.Content wrapper.
*/
interface CodeContentProps {
/** Editor and related elements */
children: ReactNode
/** Padding left (e.g., for gutter offset) */
paddingLeft?: string | number
/** Additional CSS classes */
className?: string
/** Ref for the wrapper element */
editorRef?: React.RefObject<HTMLDivElement | null>
}
/**
* Wrapper for the editor content area that applies the code theme.
* This enables VSCode-like token syntax highlighting via CSS.
*/
function Content({ children, paddingLeft, className, editorRef }: CodeContentProps) {
return (
<div
ref={editorRef}
className={cn('code-editor-theme relative mt-0 pt-0', className)}
style={paddingLeft ? { paddingLeft } : undefined}
>
{children}
</div>
)
}
/**
* Get standard Editor component props for react-simple-code-editor.
* Returns the className and textareaClassName props (no style prop).
*
* @param options - Optional overrides
* @returns Props object to spread onto Editor component
*/
export function getCodeEditorProps(options?: {
isStreaming?: boolean
isPreview?: boolean
disabled?: boolean
}) {
const { isStreaming = false, isPreview = false, disabled = false } = options || {}
return {
padding: 8,
className: cn(
// Base editor classes
'bg-transparent font-[inherit] text-[inherit] font-medium',
'text-[var(--text-primary)] dark:text-[#eeeeee]',
'leading-[21px] outline-none focus:outline-none',
'min-h-[106px]',
// Streaming/disabled states
(isStreaming || disabled) && 'cursor-not-allowed opacity-50'
),
textareaClassName: cn(
// Reset browser defaults
'border-none bg-transparent outline-none resize-none',
'focus:outline-none focus:ring-0',
// Selection styling - light and dark modes
'selection:bg-[#add6ff] selection:text-[#1b1b1b]',
'dark:selection:bg-[#264f78] dark:selection:text-white',
// Caret color - adapts to mode
'caret-[var(--text-primary)] dark:caret-white',
// Font smoothing
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
// Disable interaction for streaming/preview
(isStreaming || isPreview) && 'pointer-events-none'
),
}
}
/**
* Props for the Code.Gutter (line numbers) component.
*/
interface CodeGutterProps {
/** Line number elements to render */
children: ReactNode
/** Width of the gutter in pixels */
width: number
/** Additional CSS classes */
className?: string
/** Inline styles */
style?: React.CSSProperties
}
/**
* Code editor gutter for line numbers.
* Provides consistent styling for the line number column.
*/
function Gutter({ children, width, className, style }: CodeGutterProps) {
return (
<div
className={cn(
'absolute top-0 bottom-0 left-0',
'flex select-none flex-col items-end overflow-hidden',
'rounded-l-[4px] bg-[var(--surface-1)] dark:bg-[#1F1F1F]',
'pr-0.5',
className
)}
style={{ width: `${width}px`, paddingTop: '8.5px', ...style }}
aria-hidden='true'
>
{children}
</div>
)
}
/**
* Props for the Code.Placeholder component.
*/
interface CodePlaceholderProps {
/** Placeholder text to display */
children: ReactNode
/** Width of the gutter (for proper left positioning) */
gutterWidth: string | number
/** Whether code editor has content */
show: boolean
/** Additional CSS classes */
className?: string
}
/**
* Code editor placeholder that appears when the editor is empty.
* Automatically positioned to match the editor's text position.
*
* @example
* ```tsx
* <Code.Content paddingLeft={gutterWidth}>
* <Code.Placeholder gutterWidth={gutterWidth} show={code.length === 0}>
* Write your code here...
* </Code.Placeholder>
* <Editor {...props} />
* </Code.Content>
* ```
*/
function Placeholder({ children, gutterWidth, show, className }: CodePlaceholderProps) {
if (!show) return null
return (
<pre
className={cn(
'pointer-events-none absolute select-none overflow-visible',
'whitespace-pre-wrap text-muted-foreground/50',
className
)}
style={{
top: '8.5px',
left: `calc(${typeof gutterWidth === 'number' ? `${gutterWidth}px` : gutterWidth} + 8px)`,
fontFamily: 'inherit',
margin: 0,
lineHeight: `${CODE_LINE_HEIGHT_PX}px`,
}}
>
{children}
</pre>
)
}
/**
* Represents a highlighted line of code.
*/
interface HighlightedLine {
/** 1-based line number */
lineNumber: number
/** Syntax-highlighted HTML content */
html: string
}
/**
* Props for virtualized row rendering.
*/
interface CodeRowProps {
/** Array of highlighted lines to render */
lines: HighlightedLine[]
/** Width of the gutter in pixels */
gutterWidth: number
/** Whether to show the line number gutter */
showGutter: boolean
/** Custom styles for the gutter */
gutterStyle?: React.CSSProperties
/** Left offset for alignment */
leftOffset: number
/** Whether to wrap long lines */
wrapText: boolean
/** Whether to show the collapse column */
showCollapseColumn: boolean
/** Set of line indices that can be collapsed */
collapsibleLines: Set<number>
/** Set of line indices that are currently collapsed */
collapsedLines: Set<number>
/** Handler for toggling collapse state */
onToggleCollapse: (lineIndex: number) => void
}
/**
* Row component for virtualized code viewer.
* Renders a single line with optional gutter and collapse button.
*/
function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
const {
lines,
gutterWidth,
showGutter,
gutterStyle,
leftOffset,
wrapText,
showCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse,
} = props
const line = lines[index]
const originalLineIndex = line.lineNumber - 1
const isCollapsible = showCollapseColumn && collapsibleLines.has(originalLineIndex)
const isCollapsed = collapsedLines.has(originalLineIndex)
return (
<div style={style} className={cn('flex', wrapText && 'overflow-hidden')} data-row-index={index}>
{showGutter && (
<div
className='flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[#a8a8a8]'
style={{ width: gutterWidth, marginLeft: leftOffset, ...gutterStyle }}
>
{line.lineNumber}
</div>
)}
{showCollapseColumn && (
<div
className='ml-1 flex flex-shrink-0 items-start justify-end'
style={{ width: COLLAPSE_COLUMN_WIDTH }}
>
{isCollapsible && (
<CollapseButton
isCollapsed={isCollapsed}
onClick={() => onToggleCollapse(originalLineIndex)}
/>
)}
</div>
)}
<pre
className={cn(
'm-0 flex-1 pr-2 pl-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]',
wrapText ? 'min-w-0 whitespace-pre-wrap break-words' : 'whitespace-pre'
)}
dangerouslySetInnerHTML={{ __html: line.html || '&nbsp;' }}
/>
</div>
)
}
/**
* Applies search highlighting to a single line for virtualized rendering.
*
* @param html - The syntax-highlighted HTML string
* @param searchQuery - The search query to highlight
* @param currentMatchIndex - Index of the current match (for distinct highlighting)
* @param globalMatchOffset - Cumulative match count before this line
* @returns Object containing highlighted HTML and count of matches in this line
*/
function applySearchHighlightingToLine(
html: string,
searchQuery: string,
currentMatchIndex: number,
globalMatchOffset: number
): { html: string; matchesInLine: number } {
if (!searchQuery.trim()) return { html, matchesInLine: 0 }
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(`(${escaped})`, 'gi')
const parts = html.split(/(<[^>]+>)/g)
let matchesInLine = 0
const result = parts
.map((part) => {
if (part.startsWith('<') && part.endsWith('>')) {
return part
}
return part.replace(regex, (match) => {
const globalIndex = globalMatchOffset + matchesInLine
const isCurrentMatch = globalIndex === currentMatchIndex
matchesInLine++
const bgClass = isCurrentMatch
? 'bg-[#F6AD55] text-[#1a1a1a] dark:bg-[#F6AD55] dark:text-[#1a1a1a]'
: 'bg-[#FCD34D]/40 dark:bg-[#FCD34D]/30'
return `<mark class="${bgClass} rounded-[2px]" data-search-match>${match}</mark>`
})
})
.join('')
return { html: result, matchesInLine }
}
/**
* Props for the Code.Viewer component (readonly code display).
*/
interface CodeViewerProps {
/** Code content to display */
code: string
/** Whether to show line numbers gutter */
showGutter?: boolean
/** Language for syntax highlighting (default: 'json') */
language?: 'javascript' | 'json' | 'python'
/** Additional CSS classes for the container */
className?: string
/** Left padding offset (useful for terminal alignment) */
paddingLeft?: number
/** Inline styles for the gutter (e.g., to override background) */
gutterStyle?: React.CSSProperties
/** Whether to wrap text instead of using horizontal scroll */
wrapText?: boolean
/** Search query to highlight in the code */
searchQuery?: string
/** Index of the currently active match (for distinct highlighting) */
currentMatchIndex?: number
/** Callback when match count changes */
onMatchCountChange?: (count: number) => void
/** Ref for the content container (for scrolling to matches) */
contentRef?: React.RefObject<HTMLDivElement | null>
/** Enable virtualized rendering for large outputs (uses react-window) */
virtualized?: boolean
/** Whether to show a collapse column for JSON folding (only for json language) */
showCollapseColumn?: boolean
}
/**
* Escapes special regex characters in a string.
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Applies search highlighting to already syntax-highlighted HTML.
* Wraps matches in spans with appropriate highlighting classes.
*
* @param html - The syntax-highlighted HTML string
* @param searchQuery - The search query to highlight
* @param currentMatchIndex - Index of the current match (for distinct highlighting)
* @param matchCounter - Mutable counter object to track match indices across calls
* @returns The HTML with search highlighting applied
*/
function applySearchHighlighting(
html: string,
searchQuery: string,
currentMatchIndex: number,
matchCounter: { count: number }
): string {
if (!searchQuery.trim()) return html
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(`(${escaped})`, 'gi')
// We need to be careful not to match inside HTML tags
// Split by HTML tags and only process text parts
const parts = html.split(/(<[^>]+>)/g)
return parts
.map((part) => {
// If it's an HTML tag, don't modify it
if (part.startsWith('<') && part.endsWith('>')) {
return part
}
// Process text content
return part.replace(regex, (match) => {
const isCurrentMatch = matchCounter.count === currentMatchIndex
matchCounter.count++
const bgClass = isCurrentMatch
? 'bg-[#F6AD55] text-[#1a1a1a] dark:bg-[#F6AD55] dark:text-[#1a1a1a]'
: 'bg-[#FCD34D]/40 dark:bg-[#FCD34D]/30'
return `<mark class="${bgClass} rounded-[2px]" data-search-match>${match}</mark>`
})
})
.join('')
}
/**
* Props for inner viewer components (with defaults already applied).
*/
type ViewerInnerProps = {
/** Code content to display */
code: string
/** Whether to show line numbers gutter */
showGutter: boolean
/** Language for syntax highlighting */
language: 'javascript' | 'json' | 'python'
/** Additional CSS classes for the container */
className?: string
/** Left padding offset in pixels */
paddingLeft: number
/** Custom styles for the gutter */
gutterStyle?: React.CSSProperties
/** Whether to wrap long lines */
wrapText: boolean
/** Search query to highlight */
searchQuery?: string
/** Index of the current active match */
currentMatchIndex: number
/** Callback when match count changes */
onMatchCountChange?: (count: number) => void
/** Ref for the content container */
contentRef?: React.RefObject<HTMLDivElement | null>
/** Whether to show collapse column for JSON folding */
showCollapseColumn: boolean
}
/**
* Virtualized code viewer implementation using react-window.
* Optimized for large outputs with efficient scrolling and dynamic row heights.
*/
const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
code,
showGutter,
language,
className,
paddingLeft,
gutterStyle,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const listRef = useListRef(null)
const [containerHeight, setContainerHeight] = useState(400)
const dynamicRowHeight = useDynamicRowHeight({
defaultRowHeight: CODE_LINE_HEIGHT_PX,
key: wrapText ? 'wrap' : 'nowrap',
})
const lines = useMemo(() => code.split('\n'), [code])
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
const {
collapsedLines,
collapsibleLines,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
} = useJsonCollapse(lines, showCollapseColumn, language)
// Compute display lines (accounting for truncation of collapsed strings)
const displayLines = useMemo(() => {
return lines.map((line, idx) =>
collapsedStringLines.has(idx) ? truncateStringLine(line) : line
)
}, [lines, collapsedStringLines])
// Pre-compute cumulative match offsets based on DISPLAYED content (handles truncation)
const { matchOffsets, matchCount } = useMemo(() => {
if (!searchQuery?.trim()) return { matchOffsets: [], matchCount: 0 }
const offsets: number[] = []
let cumulative = 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
const visibleSet = new Set(visibleLineIndices)
for (let i = 0; i < lines.length; i++) {
offsets.push(cumulative)
// Only count matches in visible lines, using displayed (possibly truncated) content
if (visibleSet.has(i)) {
const matches = displayLines[i].match(regex)
cumulative += matches?.length ?? 0
}
}
return { matchOffsets: offsets, matchCount: cumulative }
}, [lines.length, displayLines, visibleLineIndices, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// Only process visible lines for efficiency (not all lines)
const visibleLines = useMemo(() => {
const lang = languages[language] || languages.javascript
const hasSearch = searchQuery?.trim()
return visibleLineIndices.map((idx) => {
let html = highlight(displayLines[idx], lang, language)
if (hasSearch && searchQuery) {
const result = applySearchHighlightingToLine(
html,
searchQuery,
currentMatchIndex,
matchOffsets[idx]
)
html = result.html
}
return { lineNumber: idx + 1, html }
})
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex, matchOffsets])
useEffect(() => {
if (!searchQuery?.trim() || matchCount === 0 || !listRef.current) return
let accumulated = 0
for (let i = 0; i < matchOffsets.length; i++) {
const matchesInThisLine = (matchOffsets[i + 1] ?? matchCount) - matchOffsets[i]
if (currentMatchIndex >= accumulated && currentMatchIndex < accumulated + matchesInThisLine) {
const visibleIndex = visibleLineIndices.indexOf(i)
if (visibleIndex !== -1) {
listRef.current.scrollToRow({ index: visibleIndex, align: 'center' })
}
break
}
accumulated += matchesInThisLine
}
}, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef, visibleLineIndices])
useEffect(() => {
const container = containerRef.current
if (!container) return
const parent = container.parentElement
if (!parent) return
const updateHeight = () => setContainerHeight(parent.clientHeight)
updateHeight()
const resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(parent)
return () => resizeObserver.disconnect()
}, [])
useEffect(() => {
if (!wrapText) return
const container = containerRef.current
if (!container) return
const rows = container.querySelectorAll('[data-row-index]')
if (rows.length === 0) return
return dynamicRowHeight.observeRowElements(rows)
}, [wrapText, dynamicRowHeight, visibleLines])
const setRefs = useCallback(
(el: HTMLDivElement | null) => {
containerRef.current = el
if (contentRef && 'current' in contentRef) {
contentRef.current = el
}
},
[contentRef]
)
const hasCollapsibleContent = collapsibleLines.size > 0
const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
const rowProps = useMemo(
() => ({
lines: visibleLines,
gutterWidth,
showGutter,
gutterStyle,
leftOffset: paddingLeft,
wrapText,
showCollapseColumn: effectiveShowCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse: toggleCollapse,
}),
[
visibleLines,
gutterWidth,
showGutter,
gutterStyle,
paddingLeft,
wrapText,
effectiveShowCollapseColumn,
collapsibleLines,
collapsedLines,
toggleCollapse,
]
)
return (
<div
ref={setRefs}
className={cn(
'code-editor-theme relative rounded-[4px] border border-[var(--border-1)]',
'bg-[var(--surface-1)] font-medium font-mono text-sm',
'dark:bg-[#1F1F1F]',
className
)}
style={{ height: containerHeight }}
>
<List
listRef={listRef}
defaultHeight={containerHeight}
rowCount={visibleLines.length}
rowHeight={wrapText ? dynamicRowHeight : CODE_LINE_HEIGHT_PX}
rowComponent={CodeRow}
rowProps={rowProps}
overscanCount={5}
className={wrapText ? 'overflow-x-hidden' : 'overflow-x-auto'}
/>
</div>
)
})
/**
* Non-virtualized code viewer implementation.
* Renders all lines directly without windowing.
*/
function ViewerInner({
code,
showGutter,
language,
className,
paddingLeft,
gutterStyle,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
const lines = useMemo(() => code.split('\n'), [code])
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
const {
collapsedLines,
collapsibleLines,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
} = useJsonCollapse(lines, showCollapseColumn, language)
// Compute display lines (accounting for truncation of collapsed strings)
const displayLines = useMemo(() => {
return lines.map((line, idx) =>
collapsedStringLines.has(idx) ? truncateStringLine(line) : line
)
}, [lines, collapsedStringLines])
// Pre-compute cumulative match offsets based on DISPLAYED content (handles truncation)
const { cumulativeMatches, matchCount } = useMemo(() => {
if (!searchQuery?.trim()) return { cumulativeMatches: [0], matchCount: 0 }
const cumulative: number[] = [0]
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
const visibleSet = new Set(visibleLineIndices)
for (let i = 0; i < lines.length; i++) {
const prev = cumulative[cumulative.length - 1]
// Only count matches in visible lines, using displayed content
if (visibleSet.has(i)) {
const matches = displayLines[i].match(regex)
cumulative.push(prev + (matches?.length ?? 0))
} else {
cumulative.push(prev)
}
}
return { cumulativeMatches: cumulative, matchCount: cumulative[cumulative.length - 1] }
}, [lines.length, displayLines, visibleLineIndices, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// Pre-compute highlighted lines with search for visible indices (for gutter mode)
const highlightedVisibleLines = useMemo(() => {
const lang = languages[language] || languages.javascript
if (!searchQuery?.trim()) {
return visibleLineIndices.map((idx) => ({
lineNumber: idx + 1,
html: highlight(displayLines[idx], lang, language) || '&nbsp;',
}))
}
return visibleLineIndices.map((idx) => {
let html = highlight(displayLines[idx], lang, language)
const matchCounter = { count: cumulativeMatches[idx] }
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
return { lineNumber: idx + 1, html: html || '&nbsp;' }
})
}, [
displayLines,
language,
visibleLineIndices,
searchQuery,
currentMatchIndex,
cumulativeMatches,
])
// Pre-compute simple highlighted code (for no-gutter mode)
const highlightedCode = useMemo(() => {
const lang = languages[language] || languages.javascript
const visibleCode = visibleLineIndices.map((idx) => displayLines[idx]).join('\n')
let html = highlight(visibleCode, lang, language)
if (searchQuery?.trim()) {
const matchCounter = { count: 0 }
html = applySearchHighlighting(html, searchQuery, currentMatchIndex, matchCounter)
}
return html
}, [displayLines, language, visibleLineIndices, searchQuery, currentMatchIndex])
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
const hasCollapsibleContent = collapsibleLines.size > 0
const effectiveShowCollapseColumn = showCollapseColumn && hasCollapsibleContent
const collapseColumnWidth = effectiveShowCollapseColumn ? COLLAPSE_COLUMN_WIDTH : 0
// Grid-based rendering for gutter alignment (works with wrap)
if (showGutter) {
return (
<Container className={className}>
<Content className='code-editor-theme' editorRef={contentRef}>
<div
style={{
paddingLeft,
paddingTop: '8px',
paddingBottom: '8px',
display: 'grid',
gridTemplateColumns: effectiveShowCollapseColumn
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
: `${gutterWidth}px 1fr`,
}}
>
{highlightedVisibleLines.map(({ lineNumber, html }) => {
const idx = lineNumber - 1
const isCollapsible = collapsibleLines.has(idx)
const isCollapsed = collapsedLines.has(idx)
return (
<Fragment key={idx}>
<div
className='select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[#a8a8a8]'
style={gutterStyle}
>
{lineNumber}
</div>
{effectiveShowCollapseColumn && (
<div className='ml-1 flex items-start justify-end'>
{isCollapsible && (
<CollapseButton
isCollapsed={isCollapsed}
onClick={() => toggleCollapse(idx)}
/>
)}
</div>
)}
<pre
className={cn(
'm-0 min-w-0 pr-2 pl-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]',
whitespaceClass
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</Fragment>
)
})}
</div>
</Content>
</Container>
)
}
// Simple display without gutter
return (
<Container className={className}>
<Content className='code-editor-theme' editorRef={contentRef}>
<pre
className={cn(
whitespaceClass,
'p-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
)}
style={{ paddingLeft: paddingLeft > 0 ? paddingLeft : undefined }}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</Content>
</Container>
)
}
/**
* Readonly code viewer with optional gutter and syntax highlighting.
* Routes to either standard or virtualized implementation based on the `virtualized` prop.
*
* @example
* ```tsx
* // Standard rendering
* <Code.Viewer
* code={JSON.stringify(data, null, 2)}
* showGutter
* language="json"
* searchQuery="error"
* currentMatchIndex={0}
* />
*
* // Virtualized rendering for large outputs
* <Code.Viewer
* code={largeOutput}
* showGutter
* language="json"
* virtualized
* />
* ```
*/
function Viewer({
code,
showGutter = false,
language = 'json',
className,
paddingLeft = 0,
gutterStyle,
wrapText = false,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
virtualized = false,
showCollapseColumn = false,
}: CodeViewerProps) {
const innerProps: ViewerInnerProps = {
code,
showGutter,
language,
className,
paddingLeft,
gutterStyle,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}
return virtualized ? <VirtualizedViewerInner {...innerProps} /> : <ViewerInner {...innerProps} />
}
export const Code = {
Container,
Content,
Gutter,
Placeholder,
Viewer,
}