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'