diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index 678dd6a4b..b67f63a39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -11,6 +11,7 @@ import { useRef, useState, } from 'react' +import { List, type RowComponentProps, useListRef } from 'react-window' import { Badge, ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -24,9 +25,8 @@ interface NodeEntry { } /** - * Search context for the structured output tree. - * Separates stable values (query, pathToMatchIndices) from frequently changing currentMatchIndex - * to avoid unnecessary re-renders of the entire tree. + * Search context for structured output tree. + * Separates stable values from frequently changing currentMatchIndex to avoid re-renders. */ interface SearchContextValue { query: string @@ -36,6 +36,18 @@ interface SearchContextValue { const SearchContext = createContext(null) +/** + * Configuration for virtualized rendering. + */ +const CONFIG = { + ROW_HEIGHT: 22, + INDENT_PER_LEVEL: 12, + BASE_PADDING: 20, + MAX_SEARCH_DEPTH: 100, + OVERSCAN_COUNT: 10, + VIRTUALIZATION_THRESHOLD: 200, +} as const + const BADGE_VARIANTS: Record = { string: 'green', number: 'blue', @@ -46,6 +58,9 @@ const BADGE_VARIANTS: Record = { object: 'gray', } as const +/** + * Styling constants matching the original non-virtualized implementation. + */ const STYLES = { row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', chevron: @@ -56,7 +71,7 @@ const STYLES = { summary: 'text-[12px] text-[var(--text-tertiary)]', indent: 'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]', - value: 'py-[2px] text-[13px] text-[var(--text-primary)]', + value: 'min-w-0 py-[2px] text-[13px] text-[var(--text-primary)]', emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]', matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40', currentMatchHighlight: 'bg-orange-400', @@ -64,9 +79,6 @@ const STYLES = { const EMPTY_MATCH_INDICES: number[] = [] -/** - * Returns the type label for a value - */ function getTypeLabel(value: unknown): ValueType { if (value === null) return 'null' if (value === undefined) return 'undefined' @@ -74,34 +86,22 @@ function getTypeLabel(value: unknown): ValueType { return typeof value as ValueType } -/** - * Formats a primitive value for display - */ function formatPrimitive(value: unknown): string { if (value === null) return 'null' if (value === undefined) return 'undefined' return String(value) } -/** - * Checks if a value is a primitive (not object/array) - */ function isPrimitive(value: unknown): value is null | undefined | string | number | boolean { return value === null || value === undefined || typeof value !== 'object' } -/** - * Checks if a value is an empty object or array - */ function isEmpty(value: unknown): boolean { if (Array.isArray(value)) return value.length === 0 if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0 return false } -/** - * Extracts error message from various error data formats - */ function extractErrorMessage(data: unknown): string { if (typeof data === 'string') return data if (data instanceof Error) return data.message @@ -111,9 +111,6 @@ function extractErrorMessage(data: unknown): string { return JSON.stringify(data, null, 2) } -/** - * Builds node entries from an object or array value - */ function buildEntries(value: unknown, basePath: string): NodeEntry[] { if (Array.isArray(value)) { return value.map((item, i) => ({ key: String(i), value: item, path: `${basePath}[${i}]` })) @@ -125,9 +122,6 @@ function buildEntries(value: unknown, basePath: string): NodeEntry[] { })) } -/** - * Gets the count summary for collapsed arrays/objects - */ function getCollapsedSummary(value: unknown): string | null { if (Array.isArray(value)) { const len = value.length @@ -140,9 +134,6 @@ function getCollapsedSummary(value: unknown): string | null { return null } -/** - * Computes initial expanded paths for first-level items - */ function computeInitialPaths(data: unknown, isError: boolean): Set { if (isError) return new Set(['root.error']) if (!data || typeof data !== 'object') return new Set() @@ -152,9 +143,6 @@ function computeInitialPaths(data: unknown, isError: boolean): Set { return new Set(entries) } -/** - * Gets all ancestor paths needed to reach a given path - */ function getAncestorPaths(path: string): string[] { const ancestors: string[] = [] let current = path @@ -169,9 +157,6 @@ function getAncestorPaths(path: string): string[] { return ancestors } -/** - * Finds all case-insensitive matches of a query within text - */ function findTextMatches(text: string, query: string): Array<[number, number]> { if (!query) return [] @@ -190,9 +175,6 @@ function findTextMatches(text: string, query: string): Array<[number, number]> { return matches } -/** - * Adds match entries for a primitive value at the given path - */ function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void { const text = formatPrimitive(value) const count = findTextMatches(text, query).length @@ -201,11 +183,8 @@ function addPrimitiveMatches(value: unknown, path: string, query: string, matche } } -/** - * Recursively collects all match paths across the entire data tree - */ -function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] { - if (!query) return [] +function collectAllMatchPaths(data: unknown, query: string, basePath: string, depth = 0): string[] { + if (!query || depth > CONFIG.MAX_SEARCH_DEPTH) return [] const matches: string[] = [] @@ -218,16 +197,13 @@ function collectAllMatchPaths(data: unknown, query: string, basePath: string): s if (isPrimitive(entry.value)) { addPrimitiveMatches(entry.value, entry.path, query, matches) } else { - matches.push(...collectAllMatchPaths(entry.value, query, entry.path)) + matches.push(...collectAllMatchPaths(entry.value, query, entry.path, depth + 1)) } } return matches } -/** - * Builds a map from path to array of global match indices - */ function buildPathToIndicesMap(matchPaths: string[]): Map { const map = new Map() matchPaths.forEach((path, globalIndex) => { @@ -241,29 +217,20 @@ function buildPathToIndicesMap(matchPaths: string[]): Map { return map } -interface HighlightedTextProps { - text: string - matchIndices: number[] - path: string -} - /** - * Renders text with search highlights. - * Uses context to access search state and avoid prop drilling. + * Renders text with search highlights using segments. */ -const HighlightedText = memo(function HighlightedText({ - text, - matchIndices, - path, -}: HighlightedTextProps) { - const searchContext = useContext(SearchContext) +function renderHighlightedSegments( + text: string, + query: string, + matchIndices: number[], + currentMatchIndex: number, + path: string +): React.ReactNode { + if (!query || matchIndices.length === 0) return text - if (!searchContext || matchIndices.length === 0) return <>{text} - - const textMatches = findTextMatches(text, searchContext.query) - if (textMatches.length === 0) return <>{text} - - const currentMatchIndex = searchContext.currentMatchIndexRef.current + const textMatches = findTextMatches(text, query) + if (textMatches.length === 0) return text const segments: React.ReactNode[] = [] let lastEnd = 0 @@ -297,6 +264,37 @@ const HighlightedText = memo(function HighlightedText({ } return <>{segments} +} + +interface HighlightedTextProps { + text: string + matchIndices: number[] + path: string +} + +/** + * Renders text with search highlights for non-virtualized mode. + */ +const HighlightedText = memo(function HighlightedText({ + text, + matchIndices, + path, +}: HighlightedTextProps) { + const searchContext = useContext(SearchContext) + + if (!searchContext || matchIndices.length === 0) return <>{text} + + return ( + <> + {renderHighlightedSegments( + text, + searchContext.query, + matchIndices, + searchContext.currentMatchIndexRef.current, + path + )} + + ) }) interface StructuredNodeProps { @@ -310,8 +308,8 @@ interface StructuredNodeProps { } /** - * Recursive node component for rendering structured data. - * Uses context for search state to avoid re-renders when currentMatchIndex changes. + * Recursive node component for non-virtualized rendering. + * Preserves exact original styling with border-left tree lines. */ const StructuredNode = memo(function StructuredNode({ name, @@ -406,6 +404,250 @@ const StructuredNode = memo(function StructuredNode({ ) }) +/** + * Flattened row for virtualization. + */ +interface FlatRow { + path: string + key: string + value: unknown + depth: number + type: 'header' | 'value' | 'empty' + valueType: ValueType + isExpanded: boolean + isError: boolean + collapsedSummary: string | null + displayText: string + matchIndices: number[] +} + +/** + * Flattens the tree into rows for virtualization. + */ +function flattenTree( + data: unknown, + expandedPaths: Set, + pathToMatchIndices: Map, + isError: boolean +): FlatRow[] { + const rows: FlatRow[] = [] + + if (isError) { + const errorText = extractErrorMessage(data) + const isExpanded = expandedPaths.has('root.error') + + rows.push({ + path: 'root.error', + key: 'error', + value: errorText, + depth: 0, + type: 'header', + valueType: 'string', + isExpanded, + isError: true, + collapsedSummary: null, + displayText: '', + matchIndices: [], + }) + + if (isExpanded) { + rows.push({ + path: 'root.error.value', + key: '', + value: errorText, + depth: 1, + type: 'value', + valueType: 'string', + isExpanded: false, + isError: true, + collapsedSummary: null, + displayText: errorText, + matchIndices: pathToMatchIndices.get('root.error') ?? [], + }) + } + + return rows + } + + function processNode(key: string, value: unknown, path: string, depth: number): void { + const valueType = getTypeLabel(value) + const isPrimitiveValue = isPrimitive(value) + const isEmptyValue = !isPrimitiveValue && isEmpty(value) + const isExpanded = expandedPaths.has(path) + const collapsedSummary = isPrimitiveValue ? null : getCollapsedSummary(value) + + rows.push({ + path, + key, + value, + depth, + type: 'header', + valueType, + isExpanded, + isError: false, + collapsedSummary, + displayText: '', + matchIndices: [], + }) + + if (isExpanded) { + if (isPrimitiveValue) { + rows.push({ + path: `${path}.value`, + key: '', + value, + depth: depth + 1, + type: 'value', + valueType, + isExpanded: false, + isError: false, + collapsedSummary: null, + displayText: formatPrimitive(value), + matchIndices: pathToMatchIndices.get(path) ?? [], + }) + } else if (isEmptyValue) { + rows.push({ + path: `${path}.empty`, + key: '', + value, + depth: depth + 1, + type: 'empty', + valueType, + isExpanded: false, + isError: false, + collapsedSummary: null, + displayText: Array.isArray(value) ? '[]' : '{}', + matchIndices: [], + }) + } else { + for (const entry of buildEntries(value, path)) { + processNode(entry.key, entry.value, entry.path, depth + 1) + } + } + } + } + + if (isPrimitive(data)) { + processNode('value', data, 'root.value', 0) + } else if (data && typeof data === 'object') { + for (const entry of buildEntries(data, 'root')) { + processNode(entry.key, entry.value, entry.path, 0) + } + } + + return rows +} + +/** + * Counts total visible rows for determining virtualization threshold. + */ +function countVisibleRows(data: unknown, expandedPaths: Set, isError: boolean): number { + if (isError) return expandedPaths.has('root.error') ? 2 : 1 + + let count = 0 + + function countNode(value: unknown, path: string): void { + count++ + if (!expandedPaths.has(path)) return + + if (isPrimitive(value) || isEmpty(value)) { + count++ + } else { + for (const entry of buildEntries(value, path)) { + countNode(entry.value, entry.path) + } + } + } + + if (isPrimitive(data)) { + countNode(data, 'root.value') + } else if (data && typeof data === 'object') { + for (const entry of buildEntries(data, 'root')) { + countNode(entry.value, entry.path) + } + } + + return count +} + +interface VirtualizedRowProps { + rows: FlatRow[] + onToggle: (path: string) => void + wrapText: boolean + searchQuery: string + currentMatchIndex: number +} + +/** + * Virtualized row component for large data sets. + */ +function VirtualizedRow({ index, style, ...props }: RowComponentProps) { + const { rows, onToggle, wrapText, searchQuery, currentMatchIndex } = props + const row = rows[index] + const paddingLeft = CONFIG.BASE_PADDING + row.depth * CONFIG.INDENT_PER_LEVEL + + if (row.type === 'header') { + const badgeVariant = row.isError ? 'red' : BADGE_VARIANTS[row.valueType] + + return ( +
+
onToggle(row.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle(row.path) + } + }} + role='button' + tabIndex={0} + aria-expanded={row.isExpanded} + > + + {row.key} + + + {row.valueType} + + {!row.isExpanded && row.collapsedSummary && ( + {row.collapsedSummary} + )} + +
+
+ ) + } + + if (row.type === 'empty') { + return ( +
+
{row.displayText}
+
+ ) + } + + return ( +
+
+ {renderHighlightedSegments( + row.displayText, + searchQuery, + row.matchIndices, + currentMatchIndex, + row.path + )} +
+
+ ) +} + export interface StructuredOutputProps { data: unknown wrapText?: boolean @@ -420,8 +662,8 @@ export interface StructuredOutputProps { /** * Renders structured data as nested collapsible blocks. - * Supports search with highlighting, auto-expand, and scroll-to-match. - * Uses React Context for search state to prevent re-render cascade. + * Uses virtualization for large data sets (>200 visible rows) while + * preserving exact original styling for smaller data sets. */ export const StructuredOutput = memo(function StructuredOutput({ data, @@ -441,11 +683,12 @@ export const StructuredOutput = memo(function StructuredOutput({ const prevIsErrorRef = useRef(isError) const internalRef = useRef(null) const currentMatchIndexRef = useRef(currentMatchIndex) + const listRef = useListRef(null) + const [containerHeight, setContainerHeight] = useState(400) - // Keep ref in sync currentMatchIndexRef.current = currentMatchIndex - // Force re-render of highlighted text when currentMatchIndex changes + // Force re-render when currentMatchIndex changes const [, forceUpdate] = useState(0) useEffect(() => { forceUpdate((n) => n + 1) @@ -461,6 +704,20 @@ export const StructuredOutput = memo(function StructuredOutput({ [contentRef] ) + // Measure container height + useEffect(() => { + const container = internalRef.current?.parentElement + if (!container) return + + const updateHeight = () => setContainerHeight(container.clientHeight) + updateHeight() + + const resizeObserver = new ResizeObserver(updateHeight) + resizeObserver.observe(container) + return () => resizeObserver.disconnect() + }, []) + + // Reset expanded paths when data changes useEffect(() => { if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) { prevDataRef.current = data @@ -485,6 +742,7 @@ export const StructuredOutput = memo(function StructuredOutput({ const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths]) + // Auto-expand to current match useEffect(() => { if ( allMatchPaths.length === 0 || @@ -505,19 +763,6 @@ export const StructuredOutput = memo(function StructuredOutput({ }) }, [currentMatchIndex, allMatchPaths]) - useEffect(() => { - if (allMatchPaths.length === 0) return - - const rafId = requestAnimationFrame(() => { - const match = internalRef.current?.querySelector( - `[data-match-index="${currentMatchIndex}"]` - ) as HTMLElement | null - match?.scrollIntoView({ block: 'center', behavior: 'smooth' }) - }) - - return () => cancelAnimationFrame(rafId) - }, [currentMatchIndex, allMatchPaths.length, expandedPaths]) - const handleToggle = useCallback((path: string) => { setExpandedPaths((prev) => { const next = new Set(prev) @@ -531,25 +776,58 @@ export const StructuredOutput = memo(function StructuredOutput({ }, []) const rootEntries = useMemo(() => { - if (isPrimitive(data)) { - return [{ key: 'value', value: data, path: 'root.value' }] - } + if (isPrimitive(data)) return [{ key: 'value', value: data, path: 'root.value' }] return buildEntries(data, 'root') }, [data]) - // Create stable search context value - only changes when query or pathToMatchIndices change const searchContextValue = useMemo(() => { if (!searchQuery) return null - return { - query: searchQuery, - pathToMatchIndices, - currentMatchIndexRef, - } + return { query: searchQuery, pathToMatchIndices, currentMatchIndexRef } }, [searchQuery, pathToMatchIndices]) - const containerClass = cn('flex flex-col pl-[20px]', className) + const visibleRowCount = useMemo( + () => countVisibleRows(data, expandedPaths, isError), + [data, expandedPaths, isError] + ) + const useVirtualization = visibleRowCount > CONFIG.VIRTUALIZATION_THRESHOLD - // Show "Running" badge when running with undefined data + const flatRows = useMemo(() => { + if (!useVirtualization) return [] + return flattenTree(data, expandedPaths, pathToMatchIndices, isError) + }, [data, expandedPaths, pathToMatchIndices, isError, useVirtualization]) + + // Scroll to match (virtualized) + useEffect(() => { + if (!useVirtualization || allMatchPaths.length === 0 || !listRef.current) return + + const currentPath = allMatchPaths[currentMatchIndex] + const targetPath = currentPath.endsWith('.value') ? currentPath : `${currentPath}.value` + const rowIndex = flatRows.findIndex((r) => r.path === targetPath || r.path === currentPath) + + if (rowIndex !== -1) { + listRef.current.scrollToRow({ index: rowIndex, align: 'center' }) + } + }, [currentMatchIndex, allMatchPaths, flatRows, listRef, useVirtualization]) + + // Scroll to match (non-virtualized) + useEffect(() => { + if (useVirtualization || allMatchPaths.length === 0) return + + const rafId = requestAnimationFrame(() => { + const match = internalRef.current?.querySelector( + `[data-match-index="${currentMatchIndex}"]` + ) as HTMLElement | null + match?.scrollIntoView({ block: 'center', behavior: 'smooth' }) + }) + + return () => cancelAnimationFrame(rafId) + }, [currentMatchIndex, allMatchPaths.length, expandedPaths, useVirtualization]) + + const containerClass = cn('flex flex-col pl-[20px]', wrapText && 'overflow-x-hidden', className) + const virtualizedContainerClass = cn('relative', wrapText && 'overflow-x-hidden', className) + const listClass = wrapText ? 'overflow-x-hidden' : 'overflow-x-auto' + + // Running state if (isRunning && data === undefined) { return (
@@ -563,6 +841,44 @@ export const StructuredOutput = memo(function StructuredOutput({ ) } + // Empty state + if (rootEntries.length === 0 && !isError) { + return ( +
+ null +
+ ) + } + + // Virtualized rendering + if (useVirtualization) { + return ( +
+ +
+ ) + } + + // Non-virtualized rendering (preserves exact original styling) if (isError) { return ( @@ -581,14 +897,6 @@ export const StructuredOutput = memo(function StructuredOutput({ ) } - if (rootEntries.length === 0) { - return ( -
- null -
- ) - } - return (
diff --git a/apps/sim/components/emcn/components/code/code.css b/apps/sim/components/emcn/components/code/code.css index 11083892e..be90a337a 100644 --- a/apps/sim/components/emcn/components/code/code.css +++ b/apps/sim/components/emcn/components/code/code.css @@ -37,7 +37,7 @@ .code-editor-theme .token.char, .code-editor-theme .token.builtin, .code-editor-theme .token.inserted { - color: #dc2626 !important; + color: #b45309 !important; } .code-editor-theme .token.operator, @@ -49,7 +49,7 @@ .code-editor-theme .token.atrule, .code-editor-theme .token.attr-value, .code-editor-theme .token.keyword { - color: #2563eb !important; + color: #2f55ff !important; } .code-editor-theme .token.function, @@ -119,7 +119,7 @@ .dark .code-editor-theme .token.atrule, .dark .code-editor-theme .token.attr-value, .dark .code-editor-theme .token.keyword { - color: #4db8ff !important; + color: #2fa1ff !important; } .dark .code-editor-theme .token.function,