mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-06 21:54:01 -05:00
feat: terminal serach; fix: delete-modal (#2176)
This commit is contained in:
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || ' ' }}
|
||||
/>
|
||||
</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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user