diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
index 36584ec458..daeb57f9f7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx
@@ -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 (
-
)
})
@@ -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)
diff --git a/apps/sim/components/emcn/components/code/code-optimized.tsx b/apps/sim/components/emcn/components/code/code-optimized.tsx
deleted file mode 100644
index ca76fcea8e..0000000000
--- a/apps/sim/components/emcn/components/code/code-optimized.tsx
+++ /dev/null
@@ -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
- *
- * ```
- */
-
-/**
- * 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
-}
-
-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 `${match}`
- })
- })
- .join('')
-
- return { html: result, matchesInLine }
-}
-
-function CodeRow({ index, style, ...props }: RowComponentProps) {
- const { lines, gutterWidth, showGutter, gutterStyle, leftOffset, wrapText } = props
- const line = lines[index]
-
- return (
-
- {showGutter && (
-
- {line.lineNumber}
-
- )}
-
-
- )
-}
-
-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(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 (
-
-
-
- )
-})
diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx
index d4390ff8a8..096ed82600 100644
--- a/apps/sim/components/emcn/components/code/code.tsx
+++ b/apps/sim/components/emcn/components/code/code.tsx
@@ -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) {
+ const { lines, gutterWidth, showGutter, gutterStyle, leftOffset, wrapText } = props
+ const line = lines[index]
+
+ return (
+
+ {showGutter && (
+
+ {line.lineNumber}
+
+ )}
+
+
+ )
+}
+
+/**
+ * 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 `${match}`
+ })
+ })
+ .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
+ /** 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
+}
+
+/**
+ * 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(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 (
+
+
+
+ )
+})
+
/**
* 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
*
+ *
+ * // Virtualized rendering for large outputs
+ *
* ```
*/
-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 ? :
+}
+
export const Code = {
Container,
Content,
diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts
index 706c3d5833..792b6a2d86 100644
--- a/apps/sim/components/emcn/components/index.ts
+++ b/apps/sim/components/emcn/components/index.ts
@@ -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'