feat: terminal serach; fix: delete-modal (#2176)

This commit is contained in:
Emir Karabeg
2025-12-04 00:41:38 -08:00
committed by GitHub
parent 6d4ba6d5cf
commit 36c91f4ca9
5 changed files with 399 additions and 85 deletions

View File

@@ -5,7 +5,7 @@ import { useTerminalStore } from '@/stores/terminal'
* Constants for output panel sizing
* Must match MIN_OUTPUT_PANEL_WIDTH_PX and BLOCK_COLUMN_WIDTH_PX in terminal.tsx
*/
const MIN_WIDTH = 300
const MIN_WIDTH = 440
const BLOCK_COLUMN_WIDTH = 240
/**

View File

@@ -13,12 +13,15 @@ import {
FilterX,
MoreHorizontal,
RepeatIcon,
Search,
SplitIcon,
Trash2,
X,
} from 'lucide-react'
import {
Button,
Code,
Input,
Popover,
PopoverContent,
PopoverItem,
@@ -49,7 +52,7 @@ const DEFAULT_EXPANDED_HEIGHT = 196
* Column width constants - numeric values for calculations
*/
const BLOCK_COLUMN_WIDTH_PX = 240
const MIN_OUTPUT_PANEL_WIDTH_PX = 300
const MIN_OUTPUT_PANEL_WIDTH_PX = 440
/**
* Column width constants - Tailwind classes for styling
@@ -154,7 +157,7 @@ const ToggleButton = ({
isExpanded: boolean
onClick: (e: React.MouseEvent) => void
}) => (
<Button variant='ghost' className='!p-0' onClick={onClick} aria-label='Toggle terminal'>
<Button variant='ghost' className='!p-1.5 -m-1.5' onClick={onClick} aria-label='Toggle terminal'>
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
@@ -258,8 +261,6 @@ export function Terminal() {
setOutputPanelWidth,
openOnRun,
setOpenOnRun,
// displayMode,
// setDisplayMode,
setHasHydrated,
} = useTerminalStore()
const entries = useTerminalConsoleStore((state) => state.entries)
@@ -268,7 +269,6 @@ export function Terminal() {
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
const [isToggling, setIsToggling] = useState(false)
// const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false)
const [wrapText, setWrapText] = useState(true)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [showInput, setShowInput] = useState(false)
@@ -279,6 +279,14 @@ export function Terminal() {
const [mainOptionsOpen, setMainOptionsOpen] = useState(false)
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
// Output panel search state
const [isOutputSearchActive, setIsOutputSearchActive] = useState(false)
const [outputSearchQuery, setOutputSearchQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
const outputSearchInputRef = useRef<HTMLInputElement>(null)
const outputContentRef = useRef<HTMLDivElement>(null)
// Terminal resize hooks
const { handleMouseDown } = useTerminalResize()
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
@@ -497,6 +505,50 @@ export function Terminal() {
}
}, [activeWorkflowId, clearWorkflowConsole])
/**
* Activates output search and focuses the search input.
*/
const activateOutputSearch = useCallback(() => {
setIsOutputSearchActive(true)
setTimeout(() => {
outputSearchInputRef.current?.focus()
}, 0)
}, [])
/**
* Closes output search and clears the query.
*/
const closeOutputSearch = useCallback(() => {
setIsOutputSearchActive(false)
setOutputSearchQuery('')
setMatchCount(0)
setCurrentMatchIndex(0)
}, [])
/**
* Navigates to the next match in the search results.
*/
const goToNextMatch = useCallback(() => {
if (matchCount === 0) return
setCurrentMatchIndex((prev) => (prev + 1) % matchCount)
}, [matchCount])
/**
* Navigates to the previous match in the search results.
*/
const goToPreviousMatch = useCallback(() => {
if (matchCount === 0) return
setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount)
}, [matchCount])
/**
* Handles match count change from Code.Viewer.
*/
const handleMatchCountChange = useCallback((count: number) => {
setMatchCount(count)
setCurrentMatchIndex(0)
}, [])
/**
* Handle clear console for current workflow via mouse interaction.
*/
@@ -681,20 +733,66 @@ export function Terminal() {
}, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded])
/**
* Handle Escape to unselect and Enter to re-enable auto-selection
* Handle Escape to close search or unselect entry
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedEntry) {
if (e.key === 'Escape') {
e.preventDefault()
setSelectedEntry(null)
setAutoSelectEnabled(true)
// First close search if active
if (isOutputSearchActive) {
closeOutputSearch()
return
}
// Then unselect entry
if (selectedEntry) {
setSelectedEntry(null)
setAutoSelectEnabled(true)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEntry])
}, [selectedEntry, isOutputSearchActive, closeOutputSearch])
/**
* Handle Enter/Shift+Enter for search navigation when search input is focused
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOutputSearchActive) return
const isSearchInputFocused = document.activeElement === outputSearchInputRef.current
if (e.key === 'Enter' && isSearchInputFocused && matchCount > 0) {
e.preventDefault()
if (e.shiftKey) {
goToPreviousMatch()
} else {
goToNextMatch()
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOutputSearchActive, matchCount, goToNextMatch, goToPreviousMatch])
/**
* Scroll to current match when it changes
*/
useEffect(() => {
if (!isOutputSearchActive || matchCount === 0 || !outputContentRef.current) return
// Find all match elements and scroll to the current one
const matchElements = outputContentRef.current.querySelectorAll('[data-search-match]')
const currentElement = matchElements[currentMatchIndex]
if (currentElement) {
currentElement.scrollIntoView({ block: 'center' })
}
}, [currentMatchIndex, isOutputSearchActive, matchCount])
/**
* Adjust output panel width when sidebar or panel width changes.
@@ -1206,7 +1304,7 @@ export function Terminal() {
{/* Header */}
<div
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] px-[16px]'
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[10px]'
onClick={handleHeaderClick}
>
<div className='flex items-center'>
@@ -1234,7 +1332,7 @@ export function Terminal() {
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput && '!text-[var(--text-primary)] dark:!text-[var(--text-primary)] '
showInput && '!text-[var(--text-primary)]'
)}
onClick={(e) => {
e.stopPropagation()
@@ -1249,7 +1347,47 @@ export function Terminal() {
</Button>
)}
</div>
<div className='flex items-center gap-[8px]'>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{isOutputSearchActive ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
closeOutputSearch()
}}
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Close search</span>
</Tooltip.Content>
</Tooltip.Root>
) : (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateOutputSearch()
}}
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
<Search className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Search</span>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -1262,7 +1400,7 @@ export function Terminal() {
className='!p-1.5 -m-1.5'
>
{showCopySuccess ? (
<Check className='h-3.5 w-3.5' />
<Check className='h-[12px] w-[12px]' />
) : (
<Clipboard className='h-[12px] w-[12px]' />
)}
@@ -1380,14 +1518,63 @@ export function Terminal() {
</div>
</div>
{/* Search Overlay */}
{isOutputSearchActive && (
<div
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
data-toolbar-root
data-search-active='true'
>
<Input
ref={outputSearchInputRef}
type='text'
value={outputSearchQuery}
onChange={(e) => setOutputSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={clsx(
'w-[58px] font-medium text-[11px]',
matchCount > 0
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'}
</span>
<Button
variant='ghost'
onClick={goToPreviousMatch}
aria-label='Previous match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={goToNextMatch}
aria-label='Next match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={closeOutputSearch}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Content */}
<div
className='flex-1 overflow-x-auto overflow-y-auto'
// className={clsx(
// 'flex-1 overflow-x-auto overflow-y-auto',
// displayMode === 'prettier' && 'px-[8px] pb-[8px]'
// )}
>
<div className='flex-1 overflow-x-auto overflow-y-auto'>
{shouldShowCodeDisplay ? (
<Code.Viewer
code={selectedEntry.input.code}
@@ -1399,6 +1586,10 @@ export function Terminal() {
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
searchQuery={isOutputSearchActive ? outputSearchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
) : (
<Code.Viewer
@@ -1409,21 +1600,12 @@ export function Terminal() {
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
searchQuery={isOutputSearchActive ? outputSearchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
)}
{/* ) : displayMode === 'raw' ? (
<Code.Viewer
code={JSON.stringify(outputData, null, 2)}
showGutter
language='json'
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
/>
) : (
<PrettierOutput output={outputData} wrapText={wrapText} />
)} */}
</div>
</div>
)}

View File

@@ -1,14 +1,13 @@
'use client'
import { Button } from '@/components/emcn'
import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Button } from '@/components/ui/button'
} from '@/components/emcn/components/modal/modal'
interface DeleteModalProps {
/**
@@ -60,59 +59,73 @@ export function DeleteModal({
let title = ''
if (itemType === 'workflow') {
title = isMultiple ? 'Delete workflows?' : 'Delete workflow?'
title = isMultiple ? 'Delete Workflows' : 'Delete Workflow'
} else if (itemType === 'folder') {
title = 'Delete folder?'
title = 'Delete Folder'
} else {
title = 'Delete workspace?'
title = 'Delete Workspace'
}
let description = ''
if (itemType === 'workflow') {
if (isMultiple) {
const workflowList = displayNames.join(', ')
description = `Deleting ${workflowList} will permanently remove all associated blocks, executions, and configuration.`
} else if (isSingle && displayNames.length > 0) {
description = `Deleting ${displayNames[0]} will permanently remove all associated blocks, executions, and configuration.`
} else {
description =
'Deleting this workflow will permanently remove all associated blocks, executions, and configuration.'
const renderDescription = () => {
if (itemType === 'workflow') {
if (isMultiple) {
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.join(', ')}
</span>
? This will permanently remove all associated blocks, executions, and configuration.
</>
)
}
if (isSingle && displayNames.length > 0) {
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated blocks, executions, and configuration.
</>
)
}
return 'Are you sure you want to delete this workflow? This will permanently remove all associated blocks, executions, and configuration.'
}
} else if (itemType === 'folder') {
if (isSingle && displayNames.length > 0) {
description = `Deleting ${displayNames[0]} will permanently remove all associated workflows, logs, and knowledge bases.`
} else {
description =
'Deleting this folder will permanently remove all associated workflows, logs, and knowledge bases.'
if (itemType === 'folder') {
if (isSingle && displayNames.length > 0) {
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated workflows, logs, and knowledge bases.
</>
)
}
return 'Are you sure you want to delete this folder? This will permanently remove all associated workflows, logs, and knowledge bases.'
}
} else {
description =
'Deleting this workspace will permanently remove all associated workflows, folders, logs, and knowledge bases.'
return 'Are you sure you want to delete this workspace? This will permanently remove all associated workflows, folders, logs, and knowledge bases.'
}
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent>
<ModalHeader>
<ModalTitle>{title}</ModalTitle>
<ModalDescription>
{description}{' '}
<ModalContent className='w-[400px]'>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{renderDescription()}{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</ModalDescription>
</ModalHeader>
</p>
</ModalBody>
<ModalFooter>
<Button
className='h-[32px] px-[12px]'
variant='outline'
onClick={onClose}
disabled={isDeleting}
>
<Button variant='active' onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)]'
variant='primary'
onClick={onConfirm}
disabled={isDeleting}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>

View File

@@ -1,4 +1,4 @@
import { Fragment, type ReactNode } from 'react'
import { Fragment, type ReactNode, useEffect, useMemo } from 'react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
@@ -272,11 +272,91 @@ interface CodeViewerProps {
gutterStyle?: React.CSSProperties
/** Whether to wrap text instead of using horizontal scroll */
wrapText?: boolean
/** Search query to highlight in the code */
searchQuery?: string
/** Index of the currently active match (for distinct highlighting) */
currentMatchIndex?: number
/** Callback when match count changes */
onMatchCountChange?: (count: number) => void
/** Ref for the content container (for scrolling to matches) */
contentRef?: React.RefObject<HTMLDivElement | null>
}
/**
* Escapes special regex characters in a string.
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Applies search highlighting to already syntax-highlighted HTML.
* Wraps matches in spans with appropriate highlighting classes.
*
* @param html - The syntax-highlighted HTML string
* @param searchQuery - The search query to highlight
* @param currentMatchIndex - Index of the current match (for distinct highlighting)
* @param matchCounter - Mutable counter object to track match indices across calls
* @returns The HTML with search highlighting applied
*/
function applySearchHighlighting(
html: string,
searchQuery: string,
currentMatchIndex: number,
matchCounter: { count: number }
): string {
if (!searchQuery.trim()) return html
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(`(${escaped})`, 'gi')
// We need to be careful not to match inside HTML tags
// Split by HTML tags and only process text parts
const parts = html.split(/(<[^>]+>)/g)
return parts
.map((part) => {
// If it's an HTML tag, don't modify it
if (part.startsWith('<') && part.endsWith('>')) {
return part
}
// Process text content
return part.replace(regex, (match) => {
const isCurrentMatch = matchCounter.count === currentMatchIndex
matchCounter.count++
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('')
}
/**
* Counts all matches for a search query in the given code.
*
* @param code - The raw code string
* @param searchQuery - The search query
* @returns Number of matches found
*/
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
}
/**
* Readonly code viewer with optional gutter and syntax highlighting.
* Handles all complexity internally - line numbers, gutter width calculation, and highlighting.
* Supports optional search highlighting with navigation.
*
* @example
* ```tsx
@@ -284,6 +364,8 @@ interface CodeViewerProps {
* code={JSON.stringify(data, null, 2)}
* showGutter
* language="json"
* searchQuery="error"
* currentMatchIndex={0}
* />
* ```
*/
@@ -295,9 +377,17 @@ function Viewer({
paddingLeft = 0,
gutterStyle,
wrapText = false,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
}: CodeViewerProps) {
// Apply syntax highlighting using the specified language
const highlightedCode = highlight(code, languages[language] || languages.javascript, language)
// Compute match count and notify parent
const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// Determine whitespace class based on wrap setting
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
@@ -307,10 +397,11 @@ function Viewer({
if (showGutter && wrapText) {
const lines = code.split('\n')
const gutterWidth = calculateGutterWidth(lines.length)
const matchCounter = { count: 0 }
return (
<Container className={className}>
<Content className='code-editor-theme'>
<Content className='code-editor-theme' editorRef={contentRef}>
<div
style={{
paddingLeft,
@@ -321,11 +412,22 @@ function Viewer({
}}
>
{lines.map((line, idx) => {
const perLineHighlighted = highlight(
let perLineHighlighted = highlight(
line,
languages[language] || languages.javascript,
language
)
// Apply search highlighting if query exists
if (searchQuery?.trim()) {
perLineHighlighted = applySearchHighlighting(
perLineHighlighted,
searchQuery,
currentMatchIndex,
matchCounter
)
}
return (
<Fragment key={idx}>
<div
@@ -336,7 +438,6 @@ function Viewer({
</div>
<pre
className='m-0 min-w-0 whitespace-pre-wrap break-words pr-2 pl-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
// Using per-line highlighting keeps the gutter height in sync with wrapped content
dangerouslySetInnerHTML={{ __html: perLineHighlighted || '&nbsp;' }}
/>
</Fragment>
@@ -348,11 +449,25 @@ function Viewer({
)
}
// Apply syntax highlighting
let highlightedCode = highlight(code, languages[language] || languages.javascript, language)
// Apply search highlighting if query exists
if (searchQuery?.trim()) {
const matchCounter = { count: 0 }
highlightedCode = applySearchHighlighting(
highlightedCode,
searchQuery,
currentMatchIndex,
matchCounter
)
}
if (!showGutter) {
// Simple display without gutter
return (
<Container className={className}>
<Content className='code-editor-theme'>
<Content className='code-editor-theme' editorRef={contentRef}>
<pre
className={cn(
whitespaceClass,
@@ -387,7 +502,11 @@ function Viewer({
<Gutter width={gutterWidth} style={{ left: `${paddingLeft}px`, ...gutterStyle }}>
{lineNumbers}
</Gutter>
<Content className='code-editor-theme' paddingLeft={`${gutterWidth + paddingLeft}px`}>
<Content
className='code-editor-theme'
paddingLeft={`${gutterWidth + paddingLeft}px`}
editorRef={contentRef}
>
<pre
className={cn(
whitespaceClass,

View File

@@ -54,8 +54,8 @@ export const DEFAULT_TERMINAL_HEIGHT = 196
/**
* Output panel width constraints.
*/
const MIN_OUTPUT_PANEL_WIDTH = 300
const DEFAULT_OUTPUT_PANEL_WIDTH = 400
const MIN_OUTPUT_PANEL_WIDTH = 440
const DEFAULT_OUTPUT_PANEL_WIDTH = 440
/**
* Default display mode for terminal output.