mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -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
|
* 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || ' ' }}
|
dangerouslySetInnerHTML={{ __html: perLineHighlighted || ' ' }}
|
||||||
/>
|
/>
|
||||||
</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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user