improvement(code): removed dedicated code-optimized virtualized viewer, baked it into the code component (#2234)

* improvement(code): removed dedicated code-optimized virtualized viewer, baked it into the code component

* ack PR comments
This commit is contained in:
Waleed
2025-12-06 18:12:51 -08:00
committed by GitHub
parent 6e02a73259
commit 22c9384f19
4 changed files with 337 additions and 316 deletions

View File

@@ -21,6 +21,7 @@ import {
import { useShallow } from 'zustand/react/shallow'
import {
Button,
Code,
Input,
Popover,
PopoverContent,
@@ -28,7 +29,6 @@ import {
PopoverScrollArea,
PopoverTrigger,
Tooltip,
VirtualizedCodeViewer,
} from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
@@ -258,7 +258,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
contentRef,
}: OutputCodeContentProps) {
return (
<VirtualizedCodeViewer
<Code.Viewer
code={code}
showGutter
language={language}
@@ -270,6 +270,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
currentMatchIndex={currentMatchIndex}
onMatchCountChange={onMatchCountChange}
contentRef={contentRef}
virtualized
/>
)
})
@@ -578,7 +579,7 @@ export function Terminal() {
}, [matchCount])
/**
* Handles match count change from VirtualizedCodeViewer.
* Handles match count change from Code.Viewer.
*/
const handleMatchCountChange = useCallback((count: number) => {
setMatchCount(count)

View File

@@ -1,303 +0,0 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from '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_LINE_HEIGHT_PX, calculateGutterWidth } from './code'
/**
* Virtualized code viewer for large outputs.
* Uses react-window to render only visible lines, keeping DOM minimal.
* Supports Prism syntax highlighting, line numbers, text wrapping, and search.
*
* @example
* ```tsx
* <VirtualizedCodeViewer
* code={JSON.stringify(data, null, 2)}
* showGutter
* language="json"
* wrapText
* searchQuery="error"
* currentMatchIndex={0}
* />
* ```
*/
/**
* Props for the VirtualizedCodeViewer component.
*/
interface VirtualizedCodeViewerProps {
/** 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 */
paddingLeft?: number
/** Inline styles for the gutter */
gutterStyle?: React.CSSProperties
/** Whether to wrap text */
wrapText?: boolean
/** Search query to highlight in the code */
searchQuery?: string
/** Index of the currently active match */
currentMatchIndex?: number
/** Callback when match count changes */
onMatchCountChange?: (count: number) => void
/** Ref for the content container */
contentRef?: React.RefObject<HTMLDivElement | null>
}
interface HighlightedLine {
lineNumber: number
html: string
}
interface CodeRowProps {
lines: HighlightedLine[]
gutterWidth: number
showGutter: boolean
gutterStyle?: React.CSSProperties
leftOffset: number
wrapText: boolean
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function countSearchMatches(code: string, searchQuery: string): number {
if (!searchQuery.trim()) return 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
const matches = code.match(regex)
return matches?.length ?? 0
}
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 }
}
function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
const { lines, gutterWidth, showGutter, gutterStyle, leftOffset, wrapText } = props
const line = lines[index]
return (
<div style={style} className='flex' 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>
)}
<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 ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
)}
dangerouslySetInnerHTML={{ __html: line.html || '&nbsp;' }}
/>
</div>
)
}
export const VirtualizedCodeViewer = memo(function VirtualizedCodeViewer({
code,
showGutter = true,
language = 'json',
className,
paddingLeft = 0,
gutterStyle,
wrapText = false,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
}: VirtualizedCodeViewerProps) {
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 matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
const lines = useMemo(() => code.split('\n'), [code])
const lineCount = lines.length
const gutterWidth = useMemo(() => calculateGutterWidth(lineCount), [lineCount])
const highlightedLines = useMemo(() => {
const lang = languages[language] || languages.javascript
return lines.map((line, idx) => ({
lineNumber: idx + 1,
html: highlight(line, lang, language),
}))
}, [lines, language])
const matchOffsets = useMemo(() => {
if (!searchQuery?.trim()) return []
const offsets: number[] = []
let cumulative = 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
for (const line of lines) {
offsets.push(cumulative)
const matches = line.match(regex)
cumulative += matches?.length ?? 0
}
return offsets
}, [lines, searchQuery])
const linesWithSearch = useMemo(() => {
if (!searchQuery?.trim()) return highlightedLines
return highlightedLines.map((line, idx) => {
const { html } = applySearchHighlightingToLine(
line.html,
searchQuery,
currentMatchIndex,
matchOffsets[idx]
)
return { ...line, html }
})
}, [highlightedLines, 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) {
listRef.current.scrollToRow({ index: i, align: 'center' })
break
}
accumulated += matchesInThisLine
}
}, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef])
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, linesWithSearch])
const setRefs = useCallback(
(el: HTMLDivElement | null) => {
containerRef.current = el
if (contentRef && 'current' in contentRef) {
contentRef.current = el
}
},
[contentRef]
)
const rowProps = useMemo(
() => ({
lines: linesWithSearch,
gutterWidth,
showGutter,
gutterStyle,
leftOffset: paddingLeft,
wrapText,
}),
[linesWithSearch, gutterWidth, showGutter, gutterStyle, paddingLeft, wrapText]
)
return (
<div
ref={setRefs}
className={cn(
'code-editor-theme relative rounded-[4px] border border-[var(--border-strong)]',
'bg-[var(--surface-1)] font-medium font-mono text-sm',
'dark:bg-[#1F1F1F]',
className
)}
style={{ height: containerHeight }}
>
<List
listRef={listRef}
defaultHeight={containerHeight}
rowCount={lineCount}
rowHeight={wrapText ? dynamicRowHeight : CODE_LINE_HEIGHT_PX}
rowComponent={CodeRow}
rowProps={rowProps}
overscanCount={5}
className='overflow-x-auto'
/>
</div>
)
})

View File

@@ -1,5 +1,17 @@
import { Fragment, type ReactNode, useEffect, useMemo } from 'react'
'use client'
import {
Fragment,
memo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from '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'
@@ -254,6 +266,89 @@ function Placeholder({ children, gutterWidth, show, className }: CodePlaceholder
)
}
/**
* Props for virtualized row rendering.
*/
interface HighlightedLine {
lineNumber: number
html: string
}
interface CodeRowProps {
lines: HighlightedLine[]
gutterWidth: number
showGutter: boolean
gutterStyle?: React.CSSProperties
leftOffset: number
wrapText: boolean
}
/**
* Row component for virtualized code viewer.
*/
function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
const { lines, gutterWidth, showGutter, gutterStyle, leftOffset, wrapText } = props
const line = lines[index]
return (
<div style={style} className='flex' 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>
)}
<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 ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
)}
dangerouslySetInnerHTML={{ __html: line.html || '&nbsp;' }}
/>
</div>
)
}
/**
* Applies search highlighting to a single line for virtualized rendering.
*/
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).
*/
@@ -280,6 +375,8 @@ interface CodeViewerProps {
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
}
/**
@@ -353,6 +450,187 @@ function countSearchMatches(code: string, searchQuery: string): number {
return matches?.length ?? 0
}
/**
* Props for inner viewer components (with defaults already applied).
*/
type ViewerInnerProps = {
code: string
showGutter: boolean
language: 'javascript' | 'json' | 'python'
className?: string
paddingLeft: number
gutterStyle?: React.CSSProperties
wrapText: boolean
searchQuery?: string
currentMatchIndex: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
}
/**
* Virtualized code viewer implementation using react-window.
*/
const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
code,
showGutter,
language,
className,
paddingLeft,
gutterStyle,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
}: 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 matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
const lines = useMemo(() => code.split('\n'), [code])
const lineCount = lines.length
const gutterWidth = useMemo(() => calculateGutterWidth(lineCount), [lineCount])
const highlightedLines = useMemo(() => {
const lang = languages[language] || languages.javascript
return lines.map((line, idx) => ({
lineNumber: idx + 1,
html: highlight(line, lang, language),
}))
}, [lines, language])
const matchOffsets = useMemo(() => {
if (!searchQuery?.trim()) return []
const offsets: number[] = []
let cumulative = 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
for (const line of lines) {
offsets.push(cumulative)
const matches = line.match(regex)
cumulative += matches?.length ?? 0
}
return offsets
}, [lines, searchQuery])
const linesWithSearch = useMemo(() => {
if (!searchQuery?.trim()) return highlightedLines
return highlightedLines.map((line, idx) => {
const { html } = applySearchHighlightingToLine(
line.html,
searchQuery,
currentMatchIndex,
matchOffsets[idx]
)
return { ...line, html }
})
}, [highlightedLines, 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) {
listRef.current.scrollToRow({ index: i, align: 'center' })
break
}
accumulated += matchesInThisLine
}
}, [currentMatchIndex, searchQuery, matchCount, matchOffsets, listRef])
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, linesWithSearch])
const setRefs = useCallback(
(el: HTMLDivElement | null) => {
containerRef.current = el
if (contentRef && 'current' in contentRef) {
contentRef.current = el
}
},
[contentRef]
)
const rowProps = useMemo(
() => ({
lines: linesWithSearch,
gutterWidth,
showGutter,
gutterStyle,
leftOffset: paddingLeft,
wrapText,
}),
[linesWithSearch, gutterWidth, showGutter, gutterStyle, paddingLeft, wrapText]
)
return (
<div
ref={setRefs}
className={cn(
'code-editor-theme relative rounded-[4px] border border-[var(--border-strong)]',
'bg-[var(--surface-1)] font-medium font-mono text-sm',
'dark:bg-[#1F1F1F]',
className
)}
style={{ height: containerHeight }}
>
<List
listRef={listRef}
defaultHeight={containerHeight}
rowCount={lineCount}
rowHeight={wrapText ? dynamicRowHeight : CODE_LINE_HEIGHT_PX}
rowComponent={CodeRow}
rowProps={rowProps}
overscanCount={5}
className='overflow-x-auto'
/>
</div>
)
})
/**
* Readonly code viewer with optional gutter and syntax highlighting.
* Handles all complexity internally - line numbers, gutter width calculation, and highlighting.
@@ -360,6 +638,7 @@ function countSearchMatches(code: string, searchQuery: string): number {
*
* @example
* ```tsx
* // Standard rendering
* <Code.Viewer
* code={JSON.stringify(data, null, 2)}
* showGutter
@@ -367,21 +646,32 @@ function countSearchMatches(code: string, searchQuery: string): number {
* searchQuery="error"
* currentMatchIndex={0}
* />
*
* // Virtualized rendering for large outputs
* <Code.Viewer
* code={largeOutput}
* showGutter
* language="json"
* virtualized
* />
* ```
*/
function Viewer({
/**
* Non-virtualized code viewer implementation.
*/
function ViewerInner({
code,
showGutter = false,
language = 'json',
showGutter,
language,
className,
paddingLeft = 0,
paddingLeft,
gutterStyle,
wrapText = false,
wrapText,
searchQuery,
currentMatchIndex = 0,
currentMatchIndex,
onMatchCountChange,
contentRef,
}: CodeViewerProps) {
}: ViewerInnerProps) {
// Compute match count and notify parent
const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
@@ -393,7 +683,6 @@ function Viewer({
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
// Special rendering path: when wrapping with gutter, render per-line rows so gutter stays aligned.
// This mimics editors that show a single line number for a logical line and "empty" gutter area for wrapped lines.
if (showGutter && wrapText) {
const lines = code.split('\n')
const gutterWidth = calculateGutterWidth(lines.length)
@@ -519,6 +808,41 @@ function Viewer({
)
}
/**
* Readonly code viewer with optional gutter and syntax highlighting.
* Routes to either standard or virtualized implementation based on the `virtualized` prop.
*/
function Viewer({
code,
showGutter = false,
language = 'json',
className,
paddingLeft = 0,
gutterStyle,
wrapText = false,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
virtualized = false,
}: CodeViewerProps) {
const innerProps: ViewerInnerProps = {
code,
showGutter,
language,
className,
paddingLeft,
gutterStyle,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
}
return virtualized ? <VirtualizedViewerInner {...innerProps} /> : <ViewerInner {...innerProps} />
}
export const Code = {
Container,
Content,

View File

@@ -8,7 +8,6 @@ export {
highlight,
languages,
} from './code/code'
export { VirtualizedCodeViewer } from './code/code-optimized'
export { Combobox, type ComboboxOption } from './combobox/combobox'
export { Input } from './input/input'
export { Label } from './label/label'