-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathcode.tsx
More file actions
1367 lines (1246 loc) · 41.8 KB
/
Copy pathcode.tsx
File metadata and controls
1367 lines (1246 loc) · 41.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
'use client'
import {
Fragment,
memo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronRight } from 'lucide-react'
import { cn } from '../../lib/cn'
import './code.css'
/**
* Shape of the lazily-loaded Prism module (`./prism`), narrowed to the two
* members this component uses for highlighting.
*/
type PrismModule = typeof import('./prism')
/**
* Module-level singleton promise for the lazily-loaded Prism module.
*
* Prism (core + the side-effectful JS/Python/JSON grammar registrations) is kept
* out of this module's static import graph so it never lands in bundles that only
* pull `Code` through the shared `@sim/emcn` barrel. It is loaded once per
* session on the first highlight and cached here for all subsequent viewers.
*/
let prismModulePromise: Promise<PrismModule> | null = null
/**
* The resolved Prism module, cached synchronously once the first load settles so
* later viewers can initialize from it without a null→loaded render cycle.
*/
let resolvedPrism: PrismModule | null = null
/**
* Loads the Prism module once and caches both the in-flight promise and the
* resolved module for reuse.
*
* @returns A promise resolving to the Prism highlighting utilities.
*/
function loadPrism(): Promise<PrismModule> {
if (!prismModulePromise) {
prismModulePromise = import('./prism').then((mod) => {
resolvedPrism = mod
return mod
})
}
return prismModulePromise
}
/**
* Subscribes a client component to the lazily-loaded Prism module.
*
* Seeds from {@link resolvedPrism} so viewers mounted after the first load
* highlight synchronously; otherwise returns `null` until Prism resolves (so
* callers render the plaintext fallback), then the loaded module.
*
* @returns The loaded Prism module, or `null` while loading.
*/
function usePrism(): PrismModule | null {
const [prism, setPrism] = useState<PrismModule | null>(resolvedPrism)
useEffect(() => {
if (prism) return
let active = true
loadPrism().then((mod) => {
if (active) setPrism(mod)
})
return () => {
active = false
}
}, [prism])
return prism
}
/**
* Escapes HTML special characters so raw code can be safely injected via
* `dangerouslySetInnerHTML` as the plaintext fallback before Prism loads (and
* for unknown languages). Matches Prism's own escaping for visual parity.
*
* @param text - The raw code text to escape
* @returns The HTML-escaped text
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
/**
* Highlights a single line of code, falling back to escaped plaintext when Prism
* has not loaded yet or the language grammar is unavailable.
*
* @param prism - The loaded Prism module, or `null` while loading
* @param text - The line of code to highlight
* @param language - The language key (e.g. `json`, `javascript`, `python`)
* @returns Highlighted HTML, or escaped plaintext as a fallback
*/
function highlightOrEscape(prism: PrismModule | null, text: string, language: string): string {
if (!prism) return escapeHtml(text)
const grammar = prism.languages[language] || prism.languages.javascript
return prism.highlight(text, grammar, language)
}
/**
* Code editor configuration and constants.
* All code editors in the app should use these values for consistency.
*/
export const CODE_LINE_HEIGHT_PX = 21
/**
* Gutter width values based on the number of digits in line numbers.
* Provides consistent spacing across all code editors.
*/
const GUTTER_WIDTHS = [20, 20, 30, 38, 46, 54] as const
/**
* Width of the collapse column in pixels.
*/
const COLLAPSE_COLUMN_WIDTH = 12
/**
* Calculates the dynamic gutter width based on the number of lines.
* @param lineCount - The total number of lines in the code
* @returns The gutter width in pixels
*/
export function calculateGutterWidth(lineCount: number): number {
const digits = String(lineCount).length
return GUTTER_WIDTHS[Math.min(digits - 1, GUTTER_WIDTHS.length - 1)]
}
/**
* Information about a collapsible region in code.
*/
interface CollapsibleRegion {
/** Line index where the region starts (0-based) */
startLine: number
/** Line index where the region ends (0-based, inclusive) */
endLine: number
/** Type of collapsible region */
type: 'block' | 'string'
}
/**
* Minimum string length to be considered collapsible.
*/
const MIN_COLLAPSIBLE_STRING_LENGTH = 80
/**
* Maximum length of truncated string preview when collapsed.
*/
const MAX_TRUNCATED_STRING_LENGTH = 30
/**
* Regex to match a JSON string value (key: "value" pattern).
* Pre-compiled for performance.
*/
const STRING_VALUE_REGEX = /:\s*"([^"\\]|\\.)*"[,]?\s*$/
/**
* Finds collapsible regions in JSON code by matching braces and detecting long strings.
* A region is collapsible if it spans multiple lines OR contains a long string value.
* Properly handles braces inside JSON strings by tracking string boundaries with correct
* escape sequence handling (counts consecutive backslashes to determine if quotes are escaped).
*
* @param lines - Array of code lines
* @returns Map of start line index to CollapsibleRegion
*/
function findCollapsibleRegions(lines: string[]): Map<number, CollapsibleRegion> {
const regions = new Map<number, CollapsibleRegion>()
const stringRegions = new Map<number, CollapsibleRegion>()
const stack: { char: '{' | '['; line: number }[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Detect collapsible string values (long strings on a single line)
const stringMatch = line.match(STRING_VALUE_REGEX)
if (stringMatch) {
const colonIdx = line.indexOf('":')
if (colonIdx !== -1) {
const valueStart = line.indexOf('"', colonIdx + 1)
const valueEnd = line.lastIndexOf('"')
if (valueStart !== -1 && valueEnd > valueStart) {
const stringValue = line.slice(valueStart + 1, valueEnd)
if (stringValue.length >= MIN_COLLAPSIBLE_STRING_LENGTH || stringValue.includes('\\n')) {
// Store separately to avoid conflicts with block regions
stringRegions.set(i, { startLine: i, endLine: i, type: 'string' })
}
}
}
}
// Check for block regions, skipping characters inside strings
let inString = false
for (let j = 0; j < line.length; j++) {
const char = line[j]
// Toggle string state on unescaped quotes
// Must count consecutive backslashes: odd = escaped quote, even = unescaped quote
if (char === '"') {
let backslashCount = 0
let k = j - 1
while (k >= 0 && line[k] === '\\') {
backslashCount++
k--
}
// Only toggle if quote is not escaped (even number of preceding backslashes)
if (backslashCount % 2 === 0) {
inString = !inString
}
continue
}
// Skip braces inside strings
if (inString) continue
if (char === '{' || char === '[') {
stack.push({ char, line: i })
} else if (char === '}' || char === ']') {
const expected = char === '}' ? '{' : '['
if (stack.length > 0 && stack[stack.length - 1].char === expected) {
const start = stack.pop()!
if (i > start.line) {
regions.set(start.line, {
startLine: start.line,
endLine: i,
type: 'block',
})
}
}
}
}
}
// Merge string regions only where no block region exists (block takes priority)
for (const [lineIdx, region] of stringRegions) {
if (!regions.has(lineIdx)) {
regions.set(lineIdx, region)
}
}
return regions
}
/**
* Computes visible line indices based on collapsed regions.
* Only block regions hide lines; string regions just truncate content.
*
* @param totalLines - Total number of lines
* @param collapsedLines - Set of line indices that are collapsed (start lines of regions)
* @param regions - Map of collapsible regions
* @returns Sorted array of visible line indices
*/
function computeVisibleLineIndices(
totalLines: number,
collapsedLines: Set<number>,
regions: Map<number, CollapsibleRegion>
): number[] {
if (collapsedLines.size === 0) {
return Array.from({ length: totalLines }, (_, i) => i)
}
// Build sorted list of hidden ranges (only for block regions, not string regions)
const hiddenRanges: Array<{ start: number; end: number }> = []
for (const startLine of collapsedLines) {
const region = regions.get(startLine)
if (region && region.type === 'block' && region.endLine > region.startLine + 1) {
hiddenRanges.push({ start: region.startLine + 1, end: region.endLine - 1 })
}
}
hiddenRanges.sort((a, b) => a.start - b.start)
// Merge overlapping ranges
const merged: Array<{ start: number; end: number }> = []
for (const range of hiddenRanges) {
if (merged.length === 0 || merged[merged.length - 1].end < range.start - 1) {
merged.push(range)
} else {
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, range.end)
}
}
// Build visible indices by skipping hidden ranges
const visible: number[] = []
let rangeIdx = 0
for (let i = 0; i < totalLines; i++) {
while (rangeIdx < merged.length && merged[rangeIdx].end < i) {
rangeIdx++
}
if (rangeIdx < merged.length && i >= merged[rangeIdx].start && i <= merged[rangeIdx].end) {
continue
}
visible.push(i)
}
return visible
}
/**
* Truncates a long string value in a JSON line for collapsed display.
*
* @param line - The original line content
* @returns Truncated line with ellipsis
*/
function truncateStringLine(line: string): string {
const colonIdx = line.indexOf('":')
if (colonIdx === -1) return line
const valueStart = line.indexOf('"', colonIdx + 1)
if (valueStart === -1) return line
const prefix = line.slice(0, valueStart + 1)
const suffix = line.charCodeAt(line.length - 1) === 44 /* ',' */ ? '",' : '"'
const truncated = line.slice(valueStart + 1, valueStart + 1 + MAX_TRUNCATED_STRING_LENGTH)
return `${prefix}${truncated}...${suffix}`
}
/**
* Custom hook for managing JSON collapse state and computations.
*
* @param lines - Array of code lines
* @param showCollapseColumn - Whether collapse functionality is enabled
* @param language - Programming language for syntax detection
* @returns Object containing collapse state and handlers
*/
function useJsonCollapse(
lines: string[],
showCollapseColumn: boolean,
language: string
): {
collapsedLines: Set<number>
collapsibleLines: Set<number>
collapsibleRegions: Map<number, CollapsibleRegion>
collapsedStringLines: Set<number>
visibleLineIndices: number[]
toggleCollapse: (lineIndex: number) => void
} {
const [collapsedLines, setCollapsedLines] = useState<Set<number>>(() => new Set())
const collapsibleRegions = useMemo(() => {
if (!showCollapseColumn || language !== 'json') return new Map<number, CollapsibleRegion>()
return findCollapsibleRegions(lines)
}, [lines, showCollapseColumn, language])
const collapsibleLines = useMemo(() => new Set(collapsibleRegions.keys()), [collapsibleRegions])
// Track which collapsed lines are string type (need truncation, not hiding)
const collapsedStringLines = useMemo(() => {
const stringLines = new Set<number>()
for (const lineIdx of collapsedLines) {
const region = collapsibleRegions.get(lineIdx)
if (region?.type === 'string') {
stringLines.add(lineIdx)
}
}
return stringLines
}, [collapsedLines, collapsibleRegions])
const visibleLineIndices = useMemo(() => {
if (!showCollapseColumn) {
return Array.from({ length: lines.length }, (_, i) => i)
}
return computeVisibleLineIndices(lines.length, collapsedLines, collapsibleRegions)
}, [lines.length, collapsedLines, collapsibleRegions, showCollapseColumn])
const toggleCollapse = useCallback((lineIndex: number) => {
setCollapsedLines((prev) => {
const next = new Set(prev)
if (next.has(lineIndex)) {
next.delete(lineIndex)
} else {
next.add(lineIndex)
}
return next
})
}, [])
return {
collapsedLines,
collapsibleLines,
collapsibleRegions,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
}
}
/**
* Props for the CollapseButton component.
*/
interface CollapseButtonProps {
/** Whether the region is currently collapsed */
isCollapsed: boolean
/** Handler for toggle click */
onClick: () => void
}
/**
* Collapse/expand button with chevron icon.
* Rotates chevron based on collapse state.
*/
const CollapseButton = memo(function CollapseButton({ isCollapsed, onClick }: CollapseButtonProps) {
return (
<button
type='button'
onClick={onClick}
className='relative flex h-[21px] w-[12px] cursor-pointer items-center justify-center border-none bg-transparent p-0 text-[var(--text-muted)] before:absolute before:inset-[-10px] before:content-[""] hover-hover:text-[var(--text-secondary)]'
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
>
<ChevronRight
className={cn(
'!h-[12px] !w-[12px] transition-transform duration-100',
!isCollapsed && 'rotate-90'
)}
/>
</button>
)
})
interface CollapseLineButtonProps {
lineIndex: number
isCollapsed: boolean
onToggleCollapse: (lineIndex: number) => void
}
const CollapseLineButton = memo(function CollapseLineButton({
lineIndex,
isCollapsed,
onToggleCollapse,
}: CollapseLineButtonProps) {
const toggleCollapse = useCallback(() => {
onToggleCollapse(lineIndex)
}, [lineIndex, onToggleCollapse])
return <CollapseButton isCollapsed={isCollapsed} onClick={toggleCollapse} />
})
/**
* Props for the Code.Container component.
*/
interface CodeContainerProps {
/** Editor content wrapped by this container */
children: ReactNode
/** Additional CSS classes for the container */
className?: string
/** Inline styles for the container */
style?: React.CSSProperties
/** Drag and drop handler */
onDragOver?: (e: React.DragEvent) => void
/** Drop handler */
onDrop?: (e: React.DragEvent) => void
}
/**
* Code editor container that provides consistent styling across all editors.
* Handles container chrome (border, radius, bg, font) with Tailwind.
*
* @example
* ```tsx
* <Code.Container>
* <Code.Content>
* <Editor {...props} />
* </Code.Content>
* </Code.Container>
* ```
*/
function Container({ children, className, style, onDragOver, onDrop }: CodeContainerProps) {
return (
<div
className={cn(
// Base container styling
'group relative min-h-[100px] rounded-sm border border-[var(--border-1)]',
'bg-[var(--surface-1)] font-medium font-mono text-sm transition-colors',
'dark:bg-[var(--code-bg)]',
// Overflow handling for long content
'overflow-x-auto overflow-y-auto',
className
)}
style={style}
onDragOver={onDragOver}
onDrop={onDrop}
>
{children}
</div>
)
}
/**
* Props for Code.Content wrapper.
*/
interface CodeContentProps {
/** Editor and related elements */
children: ReactNode
/** Padding left (e.g., for gutter offset) */
paddingLeft?: string | number
/** Additional CSS classes */
className?: string
/** Ref for the wrapper element */
editorRef?: React.RefObject<HTMLDivElement | null>
}
/**
* Wrapper for the editor content area that applies the code theme.
* This enables VSCode-like token syntax highlighting via CSS.
*/
function Content({ children, paddingLeft, className, editorRef }: CodeContentProps) {
return (
<div
ref={editorRef}
className={cn('code-editor-theme relative mt-0 pt-0', className)}
style={paddingLeft ? { paddingLeft } : undefined}
>
{children}
</div>
)
}
/**
* Get standard Editor component props for react-simple-code-editor.
* Returns the className and textareaClassName props (no style prop).
*
* @param options - Optional overrides
* @returns Props object to spread onto Editor component
*/
export function getCodeEditorProps(options?: {
isStreaming?: boolean
isPreview?: boolean
disabled?: boolean
}) {
const { isStreaming = false, isPreview = false, disabled = false } = options || {}
return {
padding: 8,
className: cn(
// Base editor classes
'bg-transparent font-[inherit] text-[inherit] font-medium',
'text-[var(--text-primary)] dark:text-[var(--code-foreground)]',
'leading-[21px] outline-none focus:outline-none',
'min-h-[106px]',
// Streaming/disabled states
(isStreaming || disabled) && 'cursor-not-allowed opacity-50'
),
textareaClassName: cn(
// Reset browser defaults
'border-none bg-transparent outline-none resize-none',
'focus:outline-none focus:ring-0',
// Selection styling - light and dark modes
'selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)]',
'dark:selection:bg-[var(--selection-dark)] dark:selection:text-white',
// Caret color - adapts to mode
'caret-[var(--text-primary)] dark:caret-white',
// Font smoothing
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
// Disable interaction for streaming/preview/disabled
(isStreaming || isPreview || disabled) && 'pointer-events-none'
),
}
}
/**
* Props for the Code.Gutter (line numbers) component.
*/
interface CodeGutterProps {
/** Line number elements to render */
children: ReactNode
/** Width of the gutter in pixels */
width: number
/** Additional CSS classes */
className?: string
/** Inline styles */
style?: React.CSSProperties
}
/**
* Code editor gutter for line numbers.
* Provides consistent styling for the line number column.
*/
function Gutter({ children, width, className, style }: CodeGutterProps) {
return (
<div
className={cn(
'absolute top-0 bottom-0 left-0',
'flex select-none flex-col items-end overflow-hidden',
'rounded-l-[4px] bg-[var(--surface-1)] dark:bg-[var(--code-bg)]',
'pr-0.5',
className
)}
style={{ width: `${width}px`, paddingTop: '8.5px', ...style }}
aria-hidden='true'
>
{children}
</div>
)
}
/**
* Props for the Code.Placeholder component.
*/
interface CodePlaceholderProps {
/** Placeholder text to display */
children: ReactNode
/** Width of the gutter (for proper left positioning) */
gutterWidth: string | number
/** Whether code editor has content */
show: boolean
/** Additional CSS classes */
className?: string
}
/**
* Code editor placeholder that appears when the editor is empty.
* Automatically positioned to match the editor's text position.
*
* @example
* ```tsx
* <Code.Content paddingLeft={gutterWidth}>
* <Code.Placeholder gutterWidth={gutterWidth} show={code.length === 0}>
* Write your code here...
* </Code.Placeholder>
* <Editor {...props} />
* </Code.Content>
* ```
*/
function Placeholder({ children, gutterWidth, show, className }: CodePlaceholderProps) {
if (!show) return null
return (
<pre
className={cn(
'pointer-events-none absolute select-none overflow-visible',
'whitespace-pre-wrap text-muted-foreground/50',
className
)}
style={{
top: '8.5px',
left: `calc(${typeof gutterWidth === 'number' ? `${gutterWidth}px` : gutterWidth} + 8px)`,
fontFamily: 'inherit',
margin: 0,
lineHeight: `${CODE_LINE_HEIGHT_PX}px`,
}}
>
{children}
</pre>
)
}
/**
* Represents a highlighted line of code.
*/
interface HighlightedLine {
/** 1-based line number */
lineNumber: number
/** Syntax-highlighted HTML content */
html: string
}
/**
* Props for virtualized row rendering.
*/
interface CodeRowProps {
/** Index of this row within the virtualized window */
index: number
/** The highlighted line to render */
line: HighlightedLine
/** Width of the gutter in pixels */
gutterWidth: number
/** Whether to show the line number gutter */
showGutter: boolean
/** Custom styles for the gutter */
gutterStyle?: React.CSSProperties
/** Left offset for alignment */
leftOffset: number
/** Whether to wrap long lines */
wrapText: boolean
/** Whether to show the collapse column */
showCollapseColumn: boolean
/** Set of line indices that can be collapsed */
collapsibleLines: Set<number>
/** Set of line indices that are currently collapsed */
collapsedLines: Set<number>
/** Handler for toggling collapse state */
onToggleCollapse: (lineIndex: number) => void
}
/**
* Row component for virtualized code viewer.
* Renders a single line with optional gutter and collapse button.
*/
function CodeRow({
index,
line,
gutterWidth,
showGutter,
gutterStyle,
leftOffset,
wrapText,
showCollapseColumn,
collapsibleLines,
collapsedLines,
onToggleCollapse,
}: CodeRowProps) {
const originalLineIndex = line.lineNumber - 1
const isCollapsible = showCollapseColumn && collapsibleLines.has(originalLineIndex)
const isCollapsed = collapsedLines.has(originalLineIndex)
return (
<div className={cn('flex', wrapText && 'overflow-hidden')} data-row-index={index}>
{showGutter && (
<div
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)]'
style={{ width: gutterWidth, marginLeft: leftOffset, ...gutterStyle }}
>
{line.lineNumber}
</div>
)}
{showCollapseColumn && (
<div
className='ml-1 flex flex-shrink-0 items-start justify-end'
style={{ width: COLLAPSE_COLUMN_WIDTH }}
>
{isCollapsible && (
<CollapseLineButton
lineIndex={originalLineIndex}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
)}
</div>
)}
<pre
className={cn(
'm-0 flex-1 pr-2 pl-2 font-mono text-[var(--text-primary)] text-small leading-[21px] dark:text-[var(--code-foreground)]',
wrapText ? 'min-w-0 whitespace-pre-wrap break-words' : 'whitespace-pre'
)}
dangerouslySetInnerHTML={{ __html: line.html || ' ' }}
/>
</div>
)
}
/**
* Applies search highlighting to a single line for virtualized rendering.
*
* @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 globalMatchOffset - Cumulative match count before this line
* @returns Object containing highlighted HTML and count of matches in this line
*/
function applySearchHighlightingToLine(
html: string,
searchQuery: string,
currentMatchIndex: number,
globalMatchOffset: number
): { html: string; matchesInLine: number } {
if (!searchQuery.trim()) return { html, matchesInLine: 0 }
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(`(${escaped})`, 'gi')
const parts = html.split(/(<[^>]+>)/g)
let matchesInLine = 0
const result = parts
.map((part) => {
if (part.startsWith('<') && part.endsWith('>')) {
return part
}
return part.replace(regex, (match) => {
const globalIndex = globalMatchOffset + matchesInLine
const isCurrentMatch = globalIndex === currentMatchIndex
matchesInLine++
const bgClass = isCurrentMatch
? 'bg-[var(--highlight-search-active)] text-[var(--text-primary)]'
: 'bg-[#FCD34D]/40 dark:bg-[#FCD34D]/30'
return `<mark class="${bgClass} rounded-xs" data-search-match>${match}</mark>`
})
})
.join('')
return { html: result, matchesInLine }
}
/**
* Props for the Code.Viewer component (readonly code display).
*/
interface CodeViewerProps {
/** Code content to display */
code: string
/** Whether to show line numbers gutter */
showGutter?: boolean
/** Language for syntax highlighting (default: 'json') */
language?: 'javascript' | 'json' | 'python'
/** Additional CSS classes for the container */
className?: string
/** Left padding offset (useful for terminal alignment) */
paddingLeft?: number
/** Inline styles for the gutter (e.g., to override background) */
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>
/** Enable virtualized rendering for large outputs (uses @tanstack/react-virtual) */
virtualized?: boolean
/** Whether to show a collapse column for JSON folding (only for json language) */
showCollapseColumn?: boolean
}
/**
* 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-[var(--highlight-search-active)] text-[var(--text-primary)]'
: 'bg-[#FCD34D]/40 dark:bg-[#FCD34D]/30'
return `<mark class="${bgClass} rounded-xs" data-search-match>${match}</mark>`
})
})
.join('')
}
/**
* Props for inner viewer components (with defaults already applied).
*/
type ViewerInnerProps = {
/** Code content to display */
code: string
/** Whether to show line numbers gutter */
showGutter: boolean
/** Language for syntax highlighting */
language: 'javascript' | 'json' | 'python'
/** Additional CSS classes for the container */
className?: string
/** Left padding offset in pixels */
paddingLeft: number
/** Custom styles for the gutter */
gutterStyle?: React.CSSProperties
/** Whether to wrap long lines */
wrapText: boolean
/** Search query to highlight */
searchQuery?: string
/** Index of the current active match */
currentMatchIndex: number
/** Callback when match count changes */
onMatchCountChange?: (count: number) => void
/** Ref for the content container */
contentRef?: React.RefObject<HTMLDivElement | null>
/** Whether to show collapse column for JSON folding */
showCollapseColumn: boolean
}
/**
* Virtualized code viewer implementation using @tanstack/react-virtual.
* Optimized for large outputs with efficient scrolling and dynamic row heights.
*/
const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
code,
showGutter,
language,
className,
paddingLeft,
gutterStyle,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
showCollapseColumn,
}: ViewerInnerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400)
const prism = usePrism()
const lines = useMemo(() => code.split('\n'), [code])
const gutterWidth = useMemo(() => calculateGutterWidth(lines.length), [lines.length])
const {
collapsedLines,
collapsibleLines,
collapsedStringLines,
visibleLineIndices,
toggleCollapse,
} = useJsonCollapse(lines, showCollapseColumn, language)
// Compute display lines (accounting for truncation of collapsed strings)
const displayLines = useMemo(() => {
return lines.map((line, idx) =>
collapsedStringLines.has(idx) ? truncateStringLine(line) : line
)
}, [lines, collapsedStringLines])
// Pre-compute cumulative match offsets based on DISPLAYED content (handles truncation)
const { matchOffsets, matchCount } = useMemo(() => {
if (!searchQuery?.trim()) return { matchOffsets: [], matchCount: 0 }
const offsets: number[] = []
let cumulative = 0
const escaped = escapeRegex(searchQuery)
const regex = new RegExp(escaped, 'gi')
const visibleSet = new Set(visibleLineIndices)
for (let i = 0; i < lines.length; i++) {
offsets.push(cumulative)
// Only count matches in visible lines, using displayed (possibly truncated) content
if (visibleSet.has(i)) {
const matches = displayLines[i].match(regex)
cumulative += matches?.length ?? 0
}
}
return { matchOffsets: offsets, matchCount: cumulative }
}, [lines.length, displayLines, visibleLineIndices, searchQuery])
useEffect(() => {
onMatchCountChange?.(matchCount)
}, [matchCount, onMatchCountChange])
// Only process visible lines for efficiency (not all lines)
const visibleLines = useMemo(() => {
const hasSearch = searchQuery?.trim()
return visibleLineIndices.map((idx) => {
let html = highlightOrEscape(prism, displayLines[idx], language)
if (hasSearch && searchQuery) {
const result = applySearchHighlightingToLine(
html,
searchQuery,
currentMatchIndex,
matchOffsets[idx]
)
html = result.html
}
return { lineNumber: idx + 1, html }
})
}, [
prism,
displayLines,
language,