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 * Constants for output panel sizing
* Must match MIN_OUTPUT_PANEL_WIDTH_PX and BLOCK_COLUMN_WIDTH_PX in terminal.tsx * 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 const BLOCK_COLUMN_WIDTH = 240
/** /**

View File

@@ -13,12 +13,15 @@ import {
FilterX, FilterX,
MoreHorizontal, MoreHorizontal,
RepeatIcon, RepeatIcon,
Search,
SplitIcon, SplitIcon,
Trash2, Trash2,
X,
} from 'lucide-react' } from 'lucide-react'
import { import {
Button, Button,
Code, Code,
Input,
Popover, Popover,
PopoverContent, PopoverContent,
PopoverItem, PopoverItem,
@@ -49,7 +52,7 @@ const DEFAULT_EXPANDED_HEIGHT = 196
* Column width constants - numeric values for calculations * Column width constants - numeric values for calculations
*/ */
const BLOCK_COLUMN_WIDTH_PX = 240 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 * Column width constants - Tailwind classes for styling
@@ -154,7 +157,7 @@ const ToggleButton = ({
isExpanded: boolean isExpanded: boolean
onClick: (e: React.MouseEvent) => void 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 <ChevronDown
className={clsx( className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100', 'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
@@ -258,8 +261,6 @@ export function Terminal() {
setOutputPanelWidth, setOutputPanelWidth,
openOnRun, openOnRun,
setOpenOnRun, setOpenOnRun,
// displayMode,
// setDisplayMode,
setHasHydrated, setHasHydrated,
} = useTerminalStore() } = useTerminalStore()
const entries = useTerminalConsoleStore((state) => state.entries) const entries = useTerminalConsoleStore((state) => state.entries)
@@ -268,7 +269,6 @@ export function Terminal() {
const { activeWorkflowId } = useWorkflowRegistry() const { activeWorkflowId } = useWorkflowRegistry()
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null) const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
const [isToggling, setIsToggling] = useState(false) const [isToggling, setIsToggling] = useState(false)
// const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false)
const [wrapText, setWrapText] = useState(true) const [wrapText, setWrapText] = useState(true)
const [showCopySuccess, setShowCopySuccess] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false)
const [showInput, setShowInput] = useState(false) const [showInput, setShowInput] = useState(false)
@@ -279,6 +279,14 @@ export function Terminal() {
const [mainOptionsOpen, setMainOptionsOpen] = useState(false) const [mainOptionsOpen, setMainOptionsOpen] = useState(false)
const [outputOptionsOpen, setOutputOptionsOpen] = 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 // Terminal resize hooks
const { handleMouseDown } = useTerminalResize() const { handleMouseDown } = useTerminalResize()
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize() const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
@@ -497,6 +505,50 @@ export function Terminal() {
} }
}, [activeWorkflowId, clearWorkflowConsole]) }, [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. * Handle clear console for current workflow via mouse interaction.
*/ */
@@ -681,20 +733,66 @@ export function Terminal() {
}, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded]) }, [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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedEntry) { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
// First close search if active
if (isOutputSearchActive) {
closeOutputSearch()
return
}
// Then unselect entry
if (selectedEntry) {
setSelectedEntry(null) setSelectedEntry(null)
setAutoSelectEnabled(true) setAutoSelectEnabled(true)
} }
} }
}
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('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. * Adjust output panel width when sidebar or panel width changes.
@@ -1206,7 +1304,7 @@ export function Terminal() {
{/* Header */} {/* Header */}
<div <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} onClick={handleHeaderClick}
> >
<div className='flex items-center'> <div className='flex items-center'>
@@ -1234,7 +1332,7 @@ export function Terminal() {
variant='ghost' variant='ghost'
className={clsx( className={clsx(
'px-[8px] py-[6px] text-[12px]', 'px-[8px] py-[6px] text-[12px]',
showInput && '!text-[var(--text-primary)] dark:!text-[var(--text-primary)] ' showInput && '!text-[var(--text-primary)]'
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -1249,7 +1347,47 @@ export function Terminal() {
</Button> </Button>
)} )}
</div> </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.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
@@ -1262,7 +1400,7 @@ export function Terminal() {
className='!p-1.5 -m-1.5' className='!p-1.5 -m-1.5'
> >
{showCopySuccess ? ( {showCopySuccess ? (
<Check className='h-3.5 w-3.5' /> <Check className='h-[12px] w-[12px]' />
) : ( ) : (
<Clipboard className='h-[12px] w-[12px]' /> <Clipboard className='h-[12px] w-[12px]' />
)} )}
@@ -1380,14 +1518,63 @@ export function Terminal() {
</div> </div>
</div> </div>
{/* Content */} {/* Search Overlay */}
{isOutputSearchActive && (
<div <div
className='flex-1 overflow-x-auto overflow-y-auto' 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'
// className={clsx( onClick={(e) => e.stopPropagation()}
// 'flex-1 overflow-x-auto overflow-y-auto', data-toolbar-root
// displayMode === 'prettier' && 'px-[8px] pb-[8px]' 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'>
{shouldShowCodeDisplay ? ( {shouldShowCodeDisplay ? (
<Code.Viewer <Code.Viewer
code={selectedEntry.input.code} code={selectedEntry.input.code}
@@ -1399,6 +1586,10 @@ export function Terminal() {
paddingLeft={8} paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }} gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText} wrapText={wrapText}
searchQuery={isOutputSearchActive ? outputSearchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/> />
) : ( ) : (
<Code.Viewer <Code.Viewer
@@ -1409,21 +1600,12 @@ export function Terminal() {
paddingLeft={8} paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }} gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText} 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>
</div> </div>
)} )}

View File

@@ -1,14 +1,13 @@
'use client' 'use client'
import { Button } from '@/components/emcn'
import { import {
Modal, Modal,
ModalBody,
ModalContent, ModalContent,
ModalDescription,
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
ModalTitle, } from '@/components/emcn/components/modal/modal'
} from '@/components/emcn'
import { Button } from '@/components/ui/button'
interface DeleteModalProps { interface DeleteModalProps {
/** /**
@@ -60,59 +59,73 @@ export function DeleteModal({
let title = '' let title = ''
if (itemType === 'workflow') { if (itemType === 'workflow') {
title = isMultiple ? 'Delete workflows?' : 'Delete workflow?' title = isMultiple ? 'Delete Workflows' : 'Delete Workflow'
} else if (itemType === 'folder') { } else if (itemType === 'folder') {
title = 'Delete folder?' title = 'Delete Folder'
} else { } else {
title = 'Delete workspace?' title = 'Delete Workspace'
} }
let description = '' const renderDescription = () => {
if (itemType === 'workflow') { if (itemType === 'workflow') {
if (isMultiple) { if (isMultiple) {
const workflowList = displayNames.join(', ') return (
description = `Deleting ${workflowList} will permanently remove all associated blocks, executions, and configuration.` <>
} else if (isSingle && displayNames.length > 0) { Are you sure you want to delete{' '}
description = `Deleting ${displayNames[0]} will permanently remove all associated blocks, executions, and configuration.` <span className='font-medium text-[var(--text-primary)]'>
} else { {displayNames.join(', ')}
description = </span>
'Deleting this workflow will permanently remove all associated blocks, executions, and configuration.' ? This will permanently remove all associated blocks, executions, and configuration.
</>
)
} }
} else if (itemType === 'folder') {
if (isSingle && displayNames.length > 0) { if (isSingle && displayNames.length > 0) {
description = `Deleting ${displayNames[0]} will permanently remove all associated workflows, logs, and knowledge bases.` return (
} else { <>
description = Are you sure you want to delete{' '}
'Deleting this folder will permanently remove all associated workflows, logs, and knowledge bases.' <span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated blocks, executions, and configuration.
</>
)
} }
} else { return 'Are you sure you want to delete this workflow? This will permanently remove all associated blocks, executions, and configuration.'
description = }
'Deleting this workspace will permanently remove all associated workflows, folders, 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.'
}
return 'Are you sure you want to delete this workspace? This will permanently remove all associated workflows, folders, logs, and knowledge bases.'
} }
return ( return (
<Modal open={isOpen} onOpenChange={onClose}> <Modal open={isOpen} onOpenChange={onClose}>
<ModalContent> <ModalContent className='w-[400px]'>
<ModalHeader> <ModalHeader>{title}</ModalHeader>
<ModalTitle>{title}</ModalTitle> <ModalBody>
<ModalDescription> <p className='text-[12px] text-[var(--text-tertiary)]'>
{description}{' '} {renderDescription()}{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span> <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</ModalDescription> </p>
</ModalHeader> </ModalBody>
<ModalFooter> <ModalFooter>
<Button <Button variant='active' onClick={onClose} disabled={isDeleting}>
className='h-[32px] px-[12px]'
variant='outline'
onClick={onClose}
disabled={isDeleting}
>
Cancel Cancel
</Button> </Button>
<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} onClick={onConfirm}
disabled={isDeleting} disabled={isDeleting}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
> >
{isDeleting ? 'Deleting...' : 'Delete'} {isDeleting ? 'Deleting...' : 'Delete'}
</Button> </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 { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript' import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python' import 'prismjs/components/prism-python'
@@ -272,11 +272,91 @@ interface CodeViewerProps {
gutterStyle?: React.CSSProperties gutterStyle?: React.CSSProperties
/** Whether to wrap text instead of using horizontal scroll */ /** Whether to wrap text instead of using horizontal scroll */
wrapText?: boolean 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. * Readonly code viewer with optional gutter and syntax highlighting.
* Handles all complexity internally - line numbers, gutter width calculation, and highlighting. * Handles all complexity internally - line numbers, gutter width calculation, and highlighting.
* Supports optional search highlighting with navigation.
* *
* @example * @example
* ```tsx * ```tsx
@@ -284,6 +364,8 @@ interface CodeViewerProps {
* code={JSON.stringify(data, null, 2)} * code={JSON.stringify(data, null, 2)}
* showGutter * showGutter
* language="json" * language="json"
* searchQuery="error"
* currentMatchIndex={0}
* /> * />
* ``` * ```
*/ */
@@ -295,9 +377,17 @@ function Viewer({
paddingLeft = 0, paddingLeft = 0,
gutterStyle, gutterStyle,
wrapText = false, wrapText = false,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
}: CodeViewerProps) { }: CodeViewerProps) {
// Apply syntax highlighting using the specified language // Compute match count and notify parent
const highlightedCode = highlight(code, languages[language] || languages.javascript, language) const matchCount = useMemo(() => countSearchMatches(code, searchQuery || ''), [code, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// Determine whitespace class based on wrap setting // Determine whitespace class based on wrap setting
const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre' const whitespaceClass = wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
@@ -307,10 +397,11 @@ function Viewer({
if (showGutter && wrapText) { if (showGutter && wrapText) {
const lines = code.split('\n') const lines = code.split('\n')
const gutterWidth = calculateGutterWidth(lines.length) const gutterWidth = calculateGutterWidth(lines.length)
const matchCounter = { count: 0 }
return ( return (
<Container className={className}> <Container className={className}>
<Content className='code-editor-theme'> <Content className='code-editor-theme' editorRef={contentRef}>
<div <div
style={{ style={{
paddingLeft, paddingLeft,
@@ -321,11 +412,22 @@ function Viewer({
}} }}
> >
{lines.map((line, idx) => { {lines.map((line, idx) => {
const perLineHighlighted = highlight( let perLineHighlighted = highlight(
line, line,
languages[language] || languages.javascript, languages[language] || languages.javascript,
language language
) )
// Apply search highlighting if query exists
if (searchQuery?.trim()) {
perLineHighlighted = applySearchHighlighting(
perLineHighlighted,
searchQuery,
currentMatchIndex,
matchCounter
)
}
return ( return (
<Fragment key={idx}> <Fragment key={idx}>
<div <div
@@ -336,7 +438,6 @@ function Viewer({
</div> </div>
<pre <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]' 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;' }} dangerouslySetInnerHTML={{ __html: perLineHighlighted || '&nbsp;' }}
/> />
</Fragment> </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) { if (!showGutter) {
// Simple display without gutter // Simple display without gutter
return ( return (
<Container className={className}> <Container className={className}>
<Content className='code-editor-theme'> <Content className='code-editor-theme' editorRef={contentRef}>
<pre <pre
className={cn( className={cn(
whitespaceClass, whitespaceClass,
@@ -387,7 +502,11 @@ function Viewer({
<Gutter width={gutterWidth} style={{ left: `${paddingLeft}px`, ...gutterStyle }}> <Gutter width={gutterWidth} style={{ left: `${paddingLeft}px`, ...gutterStyle }}>
{lineNumbers} {lineNumbers}
</Gutter> </Gutter>
<Content className='code-editor-theme' paddingLeft={`${gutterWidth + paddingLeft}px`}> <Content
className='code-editor-theme'
paddingLeft={`${gutterWidth + paddingLeft}px`}
editorRef={contentRef}
>
<pre <pre
className={cn( className={cn(
whitespaceClass, whitespaceClass,

View File

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