Skip to content

Commit 4a3ffea

Browse files
committed
improvement(workflow): add compact code hover previews
1 parent 98a453d commit 4a3ffea

14 files changed

Lines changed: 436 additions & 18 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
resolveCanvasSentence,
4949
} from '@/lib/workflows/blocks/canvas-sentence'
5050
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
51+
import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
5152
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
5253
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
5354
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
@@ -596,12 +597,15 @@ const SubBlockRow = memo(function SubBlockRow({
596597
webhookUrlDisplayValue ||
597598
selectorDisplayName
598599
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
600+
const codePreview =
601+
variant === 'inline-value' ? resolveCanvasCodePreview(subBlock, rawValue, rawValues) : undefined
599602

600603
return (
601604
<SubBlockRowView
602605
title={title}
603606
displayValue={displayValue}
604607
isMonospace={isMonospaceField}
608+
codePreview={codePreview}
605609
variant={variant}
606610
icon={icon}
607611
/>

apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
resolveCanvasSentence,
1818
} from '@/lib/workflows/blocks/canvas-sentence'
1919
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
20+
import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
2021
import {
2122
getDisplayValue,
2223
hasDisplayableRowValue,
@@ -562,6 +563,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
562563
<SubBlockRowView
563564
title={subBlock.title ?? subBlock.id}
564565
displayValue={displayValue}
566+
codePreview={resolveCanvasCodePreview(subBlock, rawValue, rawValues)}
565567
variant='inline-value'
566568
/>
567569
)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { resolveCanvasCodePreview } from '@/lib/workflows/blocks/code-preview'
3+
import type { SubBlockConfig } from '@/blocks/types'
4+
5+
const CODE_SUBBLOCK: SubBlockConfig = {
6+
id: 'code',
7+
type: 'code',
8+
language: 'javascript',
9+
}
10+
11+
describe('resolveCanvasCodePreview', () => {
12+
it('uses the selected language when the block has a language field', () => {
13+
expect(
14+
resolveCanvasCodePreview(CODE_SUBBLOCK, 'print("hello")', { language: 'python' })
15+
).toEqual({
16+
code: 'print("hello")',
17+
language: 'python',
18+
})
19+
})
20+
21+
it('maps the stored shell language to the Prism bash grammar', () => {
22+
expect(
23+
resolveCanvasCodePreview({ ...CODE_SUBBLOCK, language: 'shell' }, 'echo hello', {})
24+
).toEqual({
25+
code: 'echo hello',
26+
language: 'bash',
27+
})
28+
})
29+
30+
it('falls back to the subblock language when the selected language is empty', () => {
31+
expect(resolveCanvasCodePreview(CODE_SUBBLOCK, 'return true', { language: '' })).toEqual({
32+
code: 'return true',
33+
language: 'javascript',
34+
})
35+
})
36+
37+
it('does not preview non-code, password, empty, or non-string values', () => {
38+
expect(
39+
resolveCanvasCodePreview({ ...CODE_SUBBLOCK, type: 'short-input' }, 'hello', {})
40+
).toBeUndefined()
41+
expect(
42+
resolveCanvasCodePreview({ ...CODE_SUBBLOCK, password: true }, 'secret', {})
43+
).toBeUndefined()
44+
expect(resolveCanvasCodePreview(CODE_SUBBLOCK, ' ', {})).toBeUndefined()
45+
expect(resolveCanvasCodePreview(CODE_SUBBLOCK, { source: 'code' }, {})).toBeUndefined()
46+
})
47+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { CodePreview, CodePreviewLanguage } from '@sim/workflow-renderer'
2+
import type { SubBlockConfig } from '@/blocks/types'
3+
4+
/** Maps stored editor languages to the Prism grammar used by the shared viewer. */
5+
function resolveCodePreviewLanguage(language: unknown): CodePreviewLanguage {
6+
switch (language) {
7+
case 'json':
8+
case 'python':
9+
case 'javascript':
10+
return language
11+
case 'shell':
12+
return 'bash'
13+
default:
14+
return 'javascript'
15+
}
16+
}
17+
18+
/** Builds a rich preview only for safe, non-empty code fields on the canvas. */
19+
export function resolveCanvasCodePreview(
20+
subBlock: SubBlockConfig | undefined,
21+
rawValue: unknown,
22+
values: Readonly<Record<string, unknown>>
23+
): CodePreview | undefined {
24+
if (
25+
subBlock?.type !== 'code' ||
26+
subBlock.password === true ||
27+
typeof rawValue !== 'string' ||
28+
rawValue.trim().length === 0
29+
) {
30+
return undefined
31+
}
32+
33+
const language =
34+
typeof values.language === 'string' && values.language.length > 0
35+
? values.language
36+
: subBlock.language
37+
return { code: rawValue, language: resolveCodePreviewLanguage(language) }
38+
}

packages/emcn/src/components/code/code.tsx

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ function highlightOrEscape(prism: PrismModule | null, text: string, language: st
116116
* All code editors in the app should use these values for consistency.
117117
*/
118118
export const CODE_LINE_HEIGHT_PX = 21
119+
const COMPACT_CODE_LINE_HEIGHT_PX = 20
119120

120121
/**
121122
* Gutter width values based on the number of digits in line numbers.
@@ -679,6 +680,8 @@ interface CodeRowProps {
679680
showGutter: boolean
680681
/** Custom styles for the gutter */
681682
gutterStyle?: React.CSSProperties
683+
/** Visual density for read-only code. */
684+
density: CodeViewerDensity
682685
/** Left offset for alignment */
683686
leftOffset: number
684687
/** Whether to wrap long lines */
@@ -703,6 +706,7 @@ function CodeRow({
703706
gutterWidth,
704707
showGutter,
705708
gutterStyle,
709+
density,
706710
leftOffset,
707711
wrapText,
708712
showCollapseColumn,
@@ -718,7 +722,10 @@ function CodeRow({
718722
<div className={cn('flex', wrapText && 'overflow-hidden')} data-row-index={index}>
719723
{showGutter && (
720724
<div
721-
className='flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[var(--code-line-number)]'
725+
className={cn(
726+
'flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] tabular-nums dark:text-[var(--code-line-number)]',
727+
density === 'compact' ? 'text-caption leading-5' : 'text-xs leading-[21px]'
728+
)}
722729
style={{ width: gutterWidth, marginLeft: leftOffset, ...gutterStyle }}
723730
>
724731
{line.lineNumber}
@@ -740,7 +747,8 @@ function CodeRow({
740747
)}
741748
<pre
742749
className={cn(
743-
'm-0 flex-1 pr-2 pl-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]',
750+
'm-0 flex-1 pr-2 pl-2 font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
751+
density === 'compact' ? 'text-caption leading-5' : 'text-small leading-[21px]',
744752
wrapText ? 'min-w-0 whitespace-pre-wrap break-words' : 'whitespace-pre'
745753
)}
746754
dangerouslySetInnerHTML={{ __html: line.html || '&nbsp;' }}
@@ -796,6 +804,8 @@ function applySearchHighlightingToLine(
796804
/**
797805
* Props for the Code.Viewer component (readonly code display).
798806
*/
807+
type CodeViewerDensity = 'default' | 'compact'
808+
799809
interface CodeViewerProps {
800810
/** Code content to display */
801811
code: string
@@ -805,6 +815,8 @@ interface CodeViewerProps {
805815
language?: 'javascript' | 'json' | 'python' | 'bash'
806816
/** Additional CSS classes for the container */
807817
className?: string
818+
/** Visual density for read-only code. */
819+
density?: CodeViewerDensity
808820
/** Left padding offset (useful for terminal alignment) */
809821
paddingLeft?: number
810822
/** Inline styles for the gutter (e.g., to override background) */
@@ -891,6 +903,8 @@ type ViewerInnerProps = {
891903
language: 'javascript' | 'json' | 'python' | 'bash'
892904
/** Additional CSS classes for the container */
893905
className?: string
906+
/** Visual density for read-only code. */
907+
density: CodeViewerDensity
894908
/** Left padding offset in pixels */
895909
paddingLeft: number
896910
/** Custom styles for the gutter */
@@ -918,6 +932,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
918932
showGutter,
919933
language,
920934
className,
935+
density,
921936
paddingLeft,
922937
gutterStyle,
923938
wrapText,
@@ -1010,15 +1025,16 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
10101025
const virtualizer = useVirtualizer({
10111026
count: visibleLines.length,
10121027
getScrollElement: () => scrollRef.current,
1013-
estimateSize: () => CODE_LINE_HEIGHT_PX,
1028+
estimateSize: () => (density === 'compact' ? COMPACT_CODE_LINE_HEIGHT_PX : CODE_LINE_HEIGHT_PX),
10141029
overscan: 5,
10151030
})
10161031

10171032
/**
1018-
* Drop cached row measurements when leaving wrap mode: the measureElement
1019-
* refs detach with their wrapped heights still cached, and falling back to
1020-
* the fixed estimate is exactly correct for nowrap rows. Entering wrap needs
1021-
* no reset — refs re-attach and re-measure as rows render.
1033+
* Drop cached row measurements when leaving wrap mode or changing density:
1034+
* the measureElement refs detach with their wrapped heights still cached,
1035+
* and falling back to the current fixed estimate is exactly correct for
1036+
* nowrap rows. Entering wrap needs no reset — refs re-attach and re-measure
1037+
* as rows render.
10221038
*
10231039
* Deliberately NOT keyed on content (`visibleLines`): `measure()` wipes the
10241040
* cache without re-measuring mounted rows (ResizeObserver only fires on size
@@ -1029,7 +1045,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
10291045
*/
10301046
useEffect(() => {
10311047
if (!wrapText) virtualizer.measure()
1032-
}, [wrapText, virtualizer])
1048+
}, [density, wrapText, virtualizer])
10331049

10341050
useEffect(() => {
10351051
if (!searchQuery?.trim() || matchCount === 0 || !scrollRef.current) return
@@ -1107,6 +1123,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
11071123
gutterWidth={gutterWidth}
11081124
showGutter={showGutter}
11091125
gutterStyle={gutterStyle}
1126+
density={density}
11101127
leftOffset={paddingLeft}
11111128
wrapText={wrapText}
11121129
showCollapseColumn={effectiveShowCollapseColumn}
@@ -1131,6 +1148,7 @@ const ViewerInner = memo(function ViewerInner({
11311148
showGutter,
11321149
language,
11331150
className,
1151+
density,
11341152
paddingLeft,
11351153
gutterStyle,
11361154
wrapText,
@@ -1236,8 +1254,8 @@ const ViewerInner = memo(function ViewerInner({
12361254
<div
12371255
style={{
12381256
paddingLeft,
1239-
paddingTop: '8px',
1240-
paddingBottom: '8px',
1257+
paddingTop: density === 'compact' ? '6px' : '8px',
1258+
paddingBottom: density === 'compact' ? '6px' : '8px',
12411259
display: 'grid',
12421260
gridTemplateColumns: effectiveShowCollapseColumn
12431261
? `${gutterWidth}px ${collapseColumnWidth}px 1fr`
@@ -1252,7 +1270,10 @@ const ViewerInner = memo(function ViewerInner({
12521270
return (
12531271
<Fragment key={idx}>
12541272
<div
1255-
className='select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[var(--code-line-number)]'
1273+
className={cn(
1274+
'select-none pr-0.5 text-right text-[var(--text-muted)] tabular-nums dark:text-[var(--code-line-number)]',
1275+
density === 'compact' ? 'text-caption leading-5' : 'text-xs leading-[21px]'
1276+
)}
12561277
style={gutterStyle}
12571278
>
12581279
{lineNumber}
@@ -1270,7 +1291,10 @@ const ViewerInner = memo(function ViewerInner({
12701291
)}
12711292
<pre
12721293
className={cn(
1273-
'm-0 min-w-0 pr-2 pl-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]',
1294+
'm-0 min-w-0 pr-2 pl-2 font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
1295+
density === 'compact'
1296+
? 'text-caption leading-5'
1297+
: 'text-small leading-[21px]',
12741298
whitespaceClass
12751299
)}
12761300
dangerouslySetInnerHTML={{ __html: html }}
@@ -1291,7 +1315,10 @@ const ViewerInner = memo(function ViewerInner({
12911315
<pre
12921316
className={cn(
12931317
whitespaceClass,
1294-
'p-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]'
1318+
'font-mono text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
1319+
density === 'compact'
1320+
? 'px-2 py-1.5 text-caption leading-5'
1321+
: 'p-2 text-small leading-[21px]'
12951322
)}
12961323
style={{ paddingLeft: paddingLeft > 0 ? paddingLeft : undefined }}
12971324
dangerouslySetInnerHTML={{ __html: highlightedCode }}
@@ -1330,6 +1357,7 @@ function Viewer({
13301357
showGutter = false,
13311358
language = 'json',
13321359
className,
1360+
density = 'default',
13331361
paddingLeft = 0,
13341362
gutterStyle,
13351363
wrapText = false,
@@ -1345,6 +1373,7 @@ function Viewer({
13451373
showGutter,
13461374
language,
13471375
className,
1376+
density,
13481377
paddingLeft,
13491378
gutterStyle,
13501379
wrapText,

packages/emcn/src/components/popover/popover.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { createPortal } from 'react-dom'
5656
import { Check, ChevronLeft, ChevronRight, Search } from '../../icons'
5757
import { cn } from '../../lib/cn'
5858
import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
59+
import { TOOLTIP_MAX_WIDTH_PX, TOOLTIP_SURFACE_CLASS } from '../tooltip/tooltip-styles'
5960

6061
type PopoverSize = 'sm' | 'md'
6162
type PopoverColorScheme = 'default' | 'inverted'
@@ -388,6 +389,11 @@ interface PopoverContentProps
388389
* @default false
389390
*/
390391
border?: boolean
392+
/**
393+
* Applies a semantic platform surface treatment.
394+
* @default 'default'
395+
*/
396+
appearance?: 'default' | 'tooltip'
391397
/**
392398
* Flip to avoid viewport collisions
393399
* @default true
@@ -428,6 +434,7 @@ const PopoverContent = React.forwardRef<
428434
sideOffset,
429435
collisionPadding = 8,
430436
border = false,
437+
appearance = 'default',
431438
avoidCollisions = true,
432439
showArrow = false,
433440
arrowClassName,
@@ -528,8 +535,10 @@ const PopoverContent = React.forwardRef<
528535
// management to avoid conflicts between the popover's internal selection index
529536
// and the component's custom navigation state.
530537

538+
const effectiveMaxWidth =
539+
maxWidth ?? (appearance === 'tooltip' ? TOOLTIP_MAX_WIDTH_PX : undefined)
531540
const hasUserWidthConstraint =
532-
maxWidth !== undefined ||
541+
effectiveMaxWidth !== undefined ||
533542
minWidth !== undefined ||
534543
style?.minWidth !== undefined ||
535544
style?.maxWidth !== undefined ||
@@ -590,14 +599,16 @@ const PopoverContent = React.forwardRef<
590599
showArrow ? 'overflow-visible' : 'overflow-auto',
591600
STYLES.colorScheme[colorScheme].content,
592601
STYLES.content,
602+
appearance === 'tooltip' && TOOLTIP_SURFACE_CLASS,
593603
hasUserWidthConstraint &&
594604
'[&_.flex-1:not([data-popover-scroll])]:truncate [&_[data-popover-section]]:truncate',
595605
border && 'border border-[var(--border-1)]',
596606
className
597607
)}
598608
style={{
599609
maxHeight: `${maxHeight || 400}px`,
600-
maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)',
610+
maxWidth:
611+
effectiveMaxWidth !== undefined ? `${effectiveMaxWidth}px` : 'calc(100vw - 16px)',
601612
minWidth:
602613
minWidth !== undefined
603614
? `${minWidth}px`
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** Canonical maximum width shared by standard and interactive tooltip surfaces. */
2+
export const TOOLTIP_MAX_WIDTH_PX = 256
3+
4+
/** Canonical platform tooltip chrome, without positioning or content padding. */
5+
export const TOOLTIP_SURFACE_CLASS =
6+
'w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text-body)] text-caption shadow-sm'

0 commit comments

Comments
 (0)