From d8d4a6168cedc849d17d9ee3192bea323c76143c Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Tue, 27 Jan 2026 11:47:08 -0800 Subject: [PATCH] improvement(code): addressed comments --- .../components/terminal/components/index.ts | 2 +- .../components}/output-context-menu.tsx | 0 .../terminal/components/output-panel/index.ts | 2 + .../components/output-panel/output-panel.tsx | 601 ++++++++++++++++++ .../components/terminal/terminal.tsx | 560 +--------------- .../components/emcn/components/code/code.tsx | 39 +- 6 files changed, 641 insertions(+), 563 deletions(-) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/{ => output-panel/components}/output-context-menu.tsx (100%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts index 909a6f743..45411b9c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts @@ -1,2 +1,2 @@ export { LogRowContextMenu } from './log-row-context-menu' -export { OutputContextMenu } from './output-context-menu' +export { OutputPanel } from './output-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-context-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts new file mode 100644 index 000000000..38a8c8db6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/index.ts @@ -0,0 +1,2 @@ +export type { OutputPanelProps } from './output-panel' +export { OutputPanel } from './output-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx new file mode 100644 index 000000000..bdaade36d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -0,0 +1,601 @@ +'use client' + +import React, { useCallback, useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { + ArrowDown, + ArrowDownToLine, + ArrowUp, + Check, + ChevronDown, + Clipboard, + Database, + FilterX, + MoreHorizontal, + Palette, + Pause, + Search, + Trash2, + X, +} from 'lucide-react' +import Link from 'next/link' +import { + Button, + Code, + Input, + Popover, + PopoverContent, + PopoverItem, + PopoverTrigger, + Tooltip, +} from '@/components/emcn' +import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' +import type { ConsoleEntry } from '@/stores/terminal' + +interface OutputCodeContentProps { + code: string + language: 'javascript' | 'json' + wrapText: boolean + searchQuery: string | undefined + currentMatchIndex: number + onMatchCountChange: (count: number) => void + contentRef: React.RefObject +} + +const OutputCodeContent = React.memo(function OutputCodeContent({ + code, + language, + wrapText, + searchQuery, + currentMatchIndex, + onMatchCountChange, + contentRef, +}: OutputCodeContentProps) { + return ( + + ) +}) + +/** + * Reusable toggle button component + */ +const ToggleButton = ({ + isExpanded, + onClick, +}: { + isExpanded: boolean + onClick: (e: React.MouseEvent) => void +}) => ( + +) + +/** + * Props for the OutputPanel component + */ +export interface OutputPanelProps { + selectedEntry: ConsoleEntry + outputPanelWidth: number + handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void + handleHeaderClick: () => void + isExpanded: boolean + expandToLastHeight: () => void + showInput: boolean + setShowInput: (show: boolean) => void + hasInputData: boolean + isPlaygroundEnabled: boolean + shouldShowTrainingButton: boolean + isTraining: boolean + handleTrainingClick: (e: React.MouseEvent) => void + showCopySuccess: boolean + handleCopy: () => void + filteredEntries: ConsoleEntry[] + handleExportConsole: (e: React.MouseEvent) => void + hasActiveFilters: boolean + clearFilters: () => void + handleClearConsole: (e: React.MouseEvent) => void + wrapText: boolean + setWrapText: (wrap: boolean) => void + openOnRun: boolean + setOpenOnRun: (open: boolean) => void + outputOptionsOpen: boolean + setOutputOptionsOpen: (open: boolean) => void + shouldShowCodeDisplay: boolean + outputDataStringified: string + handleClearConsoleFromMenu: () => void +} + +/** + * Output panel component that manages its own search state. + */ +export const OutputPanel = React.memo(function OutputPanel({ + selectedEntry, + outputPanelWidth, + handleOutputPanelResizeMouseDown, + handleHeaderClick, + isExpanded, + expandToLastHeight, + showInput, + setShowInput, + hasInputData, + isPlaygroundEnabled, + shouldShowTrainingButton, + isTraining, + handleTrainingClick, + showCopySuccess, + handleCopy, + filteredEntries, + handleExportConsole, + hasActiveFilters, + clearFilters, + handleClearConsole, + wrapText, + setWrapText, + openOnRun, + setOpenOnRun, + outputOptionsOpen, + setOutputOptionsOpen, + shouldShowCodeDisplay, + outputDataStringified, + handleClearConsoleFromMenu, +}: OutputPanelProps) { + const outputContentRef = useRef(null) + const { + isSearchActive: isOutputSearchActive, + searchQuery: outputSearchQuery, + setSearchQuery: setOutputSearchQuery, + matchCount, + currentMatchIndex, + activateSearch: activateOutputSearch, + closeSearch: closeOutputSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef: outputSearchInputRef, + } = useCodeViewerFeatures({ + contentRef: outputContentRef, + externalWrapText: wrapText, + onWrapTextChange: setWrapText, + }) + + // Context menu state for output panel + const [hasSelection, setHasSelection] = useState(false) + const [storedSelectionText, setStoredSelectionText] = useState('') + const { + isOpen: isOutputMenuOpen, + position: outputMenuPosition, + menuRef: outputMenuRef, + handleContextMenu: handleOutputContextMenu, + closeMenu: closeOutputMenu, + } = useContextMenu() + + const handleOutputPanelContextMenu = useCallback( + (e: React.MouseEvent) => { + const selection = window.getSelection() + const selectionText = selection?.toString() || '' + setStoredSelectionText(selectionText) + setHasSelection(selectionText.length > 0) + handleOutputContextMenu(e) + }, + [handleOutputContextMenu] + ) + + const handleCopySelection = useCallback(() => { + if (storedSelectionText) { + navigator.clipboard.writeText(storedSelectionText) + } + }, [storedSelectionText]) + + /** + * Track text selection state for context menu. + * Skip updates when the context menu is open to prevent the selection + * state from changing mid-click (which would disable the copy button). + */ + useEffect(() => { + const handleSelectionChange = () => { + if (isOutputMenuOpen) return + + const selection = window.getSelection() + setHasSelection(Boolean(selection && selection.toString().length > 0)) + } + + document.addEventListener('selectionchange', handleSelectionChange) + return () => document.removeEventListener('selectionchange', handleSelectionChange) + }, [isOutputMenuOpen]) + + return ( + <> +
+ {/* Horizontal Resize Handle */} +
+ + {/* Header */} +
+
+ + {hasInputData && ( + + )} +
+
+ {isOutputSearchActive ? ( + + + + + + Close search + + + ) : ( + + + + + + Search + + + )} + + {isPlaygroundEnabled && ( + + + + + + + + Component Playground + + + )} + + {shouldShowTrainingButton && ( + + + + + + {isTraining ? 'Stop Training' : 'Train Copilot'} + + + )} + + + + + + + {showCopySuccess ? 'Copied' : 'Copy output'} + + + {filteredEntries.length > 0 && ( + + + + + + Download CSV + + + )} + {hasActiveFilters && ( + + + + + + Clear filters + + + )} + {filteredEntries.length > 0 && ( + + + + + + Clear console + + + )} + + + + + e.stopPropagation()} + style={{ minWidth: '140px', maxWidth: '160px' }} + className='gap-[2px]' + > + { + e.stopPropagation() + setWrapText(!wrapText) + }} + > + Wrap text + + { + e.stopPropagation() + setOpenOnRun(!openOnRun) + }} + > + Open on run + + + + { + e.stopPropagation() + handleHeaderClick() + }} + /> +
+
+ + {/* Search Overlay */} + {isOutputSearchActive && ( +
e.stopPropagation()} + data-toolbar-root + data-search-active='true' + > + setOutputSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-[2px] h-[23px] w-[94px] text-[12px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'} + + + + +
+ )} + + {/* Content */} +
+ {shouldShowCodeDisplay ? ( + + ) : ( + + )} +
+
+ + {/* Output Panel Context Menu */} + setWrapText(!wrapText)} + openOnRun={openOnRun} + onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)} + onClearConsole={handleClearConsoleFromMenu} + hasSelection={hasSelection} + /> + + ) +}) 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 45e633c77..616554173 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 @@ -1,14 +1,13 @@ 'use client' -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type React from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { ArrowDown, ArrowDownToLine, ArrowUp, - Check, ChevronDown, - Clipboard, Database, Filter, FilterX, @@ -16,18 +15,14 @@ import { Palette, Pause, RepeatIcon, - Search, SplitIcon, Trash2, - X, } from 'lucide-react' import Link from 'next/link' import { useShallow } from 'zustand/react/shallow' import { Badge, Button, - Code, - Input, Popover, PopoverContent, PopoverItem, @@ -41,7 +36,7 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { LogRowContextMenu, - OutputContextMenu, + OutputPanel, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components' import { useOutputPanelResize, @@ -51,7 +46,6 @@ import { import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { getBlock } from '@/blocks' import { useShowTrainingControls } from '@/hooks/queries/general-settings' -import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import { useCopilotTrainingStore } from '@/stores/copilot-training/store' import { openCopilotWithMessage } from '@/stores/notifications/utils' @@ -235,552 +229,6 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => { return false } -interface OutputCodeContentProps { - code: string - language: 'javascript' | 'json' - wrapText: boolean - searchQuery: string | undefined - currentMatchIndex: number - onMatchCountChange: (count: number) => void - contentRef: React.RefObject -} - -const OutputCodeContent = React.memo(function OutputCodeContent({ - code, - language, - wrapText, - searchQuery, - currentMatchIndex, - onMatchCountChange, - contentRef, -}: OutputCodeContentProps) { - return ( - - ) -}) - -/** - * Props for the OutputPanel component - */ -interface OutputPanelProps { - selectedEntry: ConsoleEntry - outputPanelWidth: number - handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void - handleHeaderClick: () => void - isExpanded: boolean - expandToLastHeight: () => void - showInput: boolean - setShowInput: (show: boolean) => void - hasInputData: boolean - isPlaygroundEnabled: boolean - shouldShowTrainingButton: boolean - isTraining: boolean - handleTrainingClick: (e: React.MouseEvent) => void - showCopySuccess: boolean - handleCopy: () => void - filteredEntries: ConsoleEntry[] - handleExportConsole: (e: React.MouseEvent) => void - hasActiveFilters: boolean - clearFilters: () => void - handleClearConsole: (e: React.MouseEvent) => void - wrapText: boolean - setWrapText: (wrap: boolean) => void - openOnRun: boolean - setOpenOnRun: (open: boolean) => void - outputOptionsOpen: boolean - setOutputOptionsOpen: (open: boolean) => void - shouldShowCodeDisplay: boolean - outputDataStringified: string - handleClearConsoleFromMenu: () => void -} - -/** - * Output panel component that manages its own search state. - */ -const OutputPanel = React.memo(function OutputPanel({ - selectedEntry, - outputPanelWidth, - handleOutputPanelResizeMouseDown, - handleHeaderClick, - isExpanded, - expandToLastHeight, - showInput, - setShowInput, - hasInputData, - isPlaygroundEnabled, - shouldShowTrainingButton, - isTraining, - handleTrainingClick, - showCopySuccess, - handleCopy, - filteredEntries, - handleExportConsole, - hasActiveFilters, - clearFilters, - handleClearConsole, - wrapText, - setWrapText, - openOnRun, - setOpenOnRun, - outputOptionsOpen, - setOutputOptionsOpen, - shouldShowCodeDisplay, - outputDataStringified, - handleClearConsoleFromMenu, -}: OutputPanelProps) { - const outputContentRef = useRef(null) - const { - isSearchActive: isOutputSearchActive, - searchQuery: outputSearchQuery, - setSearchQuery: setOutputSearchQuery, - matchCount, - currentMatchIndex, - activateSearch: activateOutputSearch, - closeSearch: closeOutputSearch, - goToNextMatch, - goToPreviousMatch, - handleMatchCountChange, - searchInputRef: outputSearchInputRef, - } = useCodeViewerFeatures({ - contentRef: outputContentRef, - externalWrapText: wrapText, - onWrapTextChange: setWrapText, - }) - - // Context menu state for output panel - const [hasSelection, setHasSelection] = useState(false) - const [storedSelectionText, setStoredSelectionText] = useState('') - const { - isOpen: isOutputMenuOpen, - position: outputMenuPosition, - menuRef: outputMenuRef, - handleContextMenu: handleOutputContextMenu, - closeMenu: closeOutputMenu, - } = useContextMenu() - - const handleOutputPanelContextMenu = useCallback( - (e: React.MouseEvent) => { - const selection = window.getSelection() - const selectionText = selection?.toString() || '' - setStoredSelectionText(selectionText) - setHasSelection(selectionText.length > 0) - handleOutputContextMenu(e) - }, - [handleOutputContextMenu] - ) - - const handleCopySelection = useCallback(() => { - if (storedSelectionText) { - navigator.clipboard.writeText(storedSelectionText) - } - }, [storedSelectionText]) - - /** - * Track text selection state for context menu. - * Skip updates when the context menu is open to prevent the selection - * state from changing mid-click (which would disable the copy button). - */ - useEffect(() => { - const handleSelectionChange = () => { - if (isOutputMenuOpen) return - - const selection = window.getSelection() - setHasSelection(Boolean(selection && selection.toString().length > 0)) - } - - document.addEventListener('selectionchange', handleSelectionChange) - return () => document.removeEventListener('selectionchange', handleSelectionChange) - }, [isOutputMenuOpen]) - - return ( - <> -
- {/* Horizontal Resize Handle */} -
- - {/* Header */} -
-
- - {hasInputData && ( - - )} -
-
- {isOutputSearchActive ? ( - - - - - - Close search - - - ) : ( - - - - - - Search - - - )} - - {isPlaygroundEnabled && ( - - - - - - - - Component Playground - - - )} - - {shouldShowTrainingButton && ( - - - - - - {isTraining ? 'Stop Training' : 'Train Copilot'} - - - )} - - - - - - - {showCopySuccess ? 'Copied' : 'Copy output'} - - - {filteredEntries.length > 0 && ( - - - - - - Download CSV - - - )} - {hasActiveFilters && ( - - - - - - Clear filters - - - )} - {filteredEntries.length > 0 && ( - - - - - - Clear console - - - )} - - - - - e.stopPropagation()} - style={{ minWidth: '140px', maxWidth: '160px' }} - className='gap-[2px]' - > - { - e.stopPropagation() - setWrapText(!wrapText) - }} - > - Wrap text - - { - e.stopPropagation() - setOpenOnRun(!openOnRun) - }} - > - Open on run - - - - { - e.stopPropagation() - handleHeaderClick() - }} - /> -
-
- - {/* Search Overlay */} - {isOutputSearchActive && ( -
e.stopPropagation()} - data-toolbar-root - data-search-active='true' - > - setOutputSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-[2px] h-[23px] w-[94px] text-[12px]' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'} - - - - -
- )} - - {/* Content */} -
- {shouldShowCodeDisplay ? ( - - ) : ( - - )} -
-
- - {/* Output Panel Context Menu */} - setWrapText(!wrapText)} - openOnRun={openOnRun} - onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)} - onClearConsole={handleClearConsoleFromMenu} - hasSelection={hasSelection} - /> - - ) -}) - /** * Terminal component with resizable height that persists across page refreshes. * @@ -1372,7 +820,7 @@ export const Terminal = memo(function Terminal() { }, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded]) /** - * Handle Escape to unselect entry (search close is handled by useCodeViewerFeatures) + * Handle Escape to unselect entry (search close is handled by OutputPanel internally) * Check if the focused element is in the search overlay to avoid conflicting with search close. */ useEffect(() => { diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index 87430d396..429167328 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -69,6 +69,11 @@ interface CollapsibleRegion { */ 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. @@ -78,17 +83,20 @@ 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. * * @param lines - Array of code lines * @returns Map of start line index to CollapsibleRegion */ function findCollapsibleRegions(lines: string[]): Map { const regions = new Map() + const stringRegions = new Map() 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('":') @@ -97,23 +105,35 @@ function findCollapsibleRegions(lines: string[]): Map const valueEnd = line.lastIndexOf('"') if (valueStart !== -1 && valueEnd > valueStart) { const stringValue = line.slice(valueStart + 1, valueEnd) - // Check if string is long enough or contains escaped newlines if (stringValue.length >= MIN_COLLAPSIBLE_STRING_LENGTH || stringValue.includes('\\n')) { - regions.set(i, { startLine: i, endLine: i, type: 'string' }) + // Store separately to avoid conflicts with block regions + stringRegions.set(i, { startLine: i, endLine: i, type: 'string' }) } } } } - // Check for block regions (objects/arrays) - for (const char of line) { + // Check for block regions, skipping characters inside strings + let inString = false + for (let j = 0; j < line.length; j++) { + const char = line[j] + const prevChar = j > 0 ? line[j - 1] : '' + + // Toggle string state on unescaped quotes + if (char === '"' && prevChar !== '\\') { + 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()! - // Only create a region if it spans multiple lines if (i > start.line) { regions.set(start.line, { startLine: start.line, @@ -126,6 +146,13 @@ function findCollapsibleRegions(lines: string[]): Map } } + // 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 } @@ -198,7 +225,7 @@ function truncateStringLine(line: string): string { const prefix = line.slice(0, valueStart + 1) const suffix = line.charCodeAt(line.length - 1) === 44 /* ',' */ ? '",' : '"' - const truncated = line.slice(valueStart + 1, valueStart + 31) + const truncated = line.slice(valueStart + 1, valueStart + 1 + MAX_TRUNCATED_STRING_LENGTH) return `${prefix}${truncated}...${suffix}` }