11import * as React from 'react' ;
2+ import { tokenizeLines , type Token } from '../syntax' ;
3+
4+ export interface Diagnostic {
5+ readonly startLine : number ;
6+ readonly startCol : number ;
7+ readonly endCol ?: number ;
8+ readonly severity : 'error' | 'warning' | 'info' ;
9+ readonly message ?: string ;
10+ }
211
312export interface CodeViewerProps {
413 readonly content : string ;
14+ readonly language : 'typescript' | 'json' | 'yaml' ;
515 readonly highlightStart ?: number ;
616 readonly highlightEnd ?: number ;
717 readonly highlightColor ?: string ;
818 readonly navCounter ?: number ;
919 readonly scrollToLine ?: number ;
1020 readonly onLineDoubleClick ?: ( line : number ) => void ;
21+ readonly diagnostics ?: readonly Diagnostic [ ] ;
1122}
1223
24+ const SQUIGGLE_COLORS : Record < string , string > = {
25+ error : '#d91515' ,
26+ warning : '#ff9900' ,
27+ info : '#0972d3' ,
28+ } ;
29+
30+ const TOKEN_COLORS : Record < string , string > = {
31+ keyword : '#0000ff' ,
32+ string : '#a31515' ,
33+ number : '#098658' ,
34+ boolean : '#0000ff' ,
35+ 'attr-name' : '#0451a5' ,
36+ property : '#0451a5' ,
37+ operator : '#393a34' ,
38+ punctuation : '#393a34' ,
39+ comment : '#008000' ,
40+ 'class-name' : '#267f99' ,
41+ builtin : '#267f99' ,
42+ function : '#795e26' ,
43+ tag : '#0451a5' ,
44+ selector : '#0451a5' ,
45+ key : '#0451a5' ,
46+ 'atrule' : '#0000ff' ,
47+ // YAML-specific
48+ important : '#098658' ,
49+ // null/undefined
50+ 'null' : '#0000ff' ,
51+ } ;
52+
1353export function CodeViewer ( {
1454 content,
55+ language,
1556 highlightStart,
1657 highlightEnd,
1758 highlightColor,
1859 navCounter,
1960 scrollToLine,
2061 onLineDoubleClick,
62+ diagnostics,
2163} : CodeViewerProps ) : JSX . Element {
22- const lines = React . useMemo ( ( ) => content . split ( '\n' ) , [ content ] ) ;
2364 const scrollTargetRef = React . useRef < HTMLDivElement > ( null ) ;
2465
66+ const tokenizedLines = React . useMemo (
67+ ( ) => tokenizeLines ( content , language ) ,
68+ [ content , language ] ,
69+ ) ;
70+
2571 const gutterWidth = React . useMemo ( ( ) => {
26- const digits = String ( lines . length ) . length ;
72+ const digits = String ( tokenizedLines . length ) . length ;
2773 return `${ digits * 8 + 8 } px` ;
28- } , [ lines . length ] ) ;
74+ } , [ tokenizedLines . length ] ) ;
75+
76+ const diagnosticsByLine = React . useMemo ( ( ) => {
77+ if ( ! diagnostics ?. length ) return undefined ;
78+ const map = new Map < number , Diagnostic [ ] > ( ) ;
79+ for ( const d of diagnostics ) {
80+ const arr = map . get ( d . startLine ) ?? [ ] ;
81+ arr . push ( d ) ;
82+ map . set ( d . startLine , arr ) ;
83+ }
84+ return map ;
85+ } , [ diagnostics ] ) ;
2986
3087 React . useEffect ( ( ) => {
3188 if ( scrollToLine && scrollTargetRef . current ) {
@@ -35,13 +92,15 @@ export function CodeViewer({
3592
3693 return (
3794 < div style = { CONTAINER_STYLE } >
38- { lines . map ( ( line , i ) => {
95+ { tokenizedLines . map ( ( lineTokens , i ) => {
3996 const lineNum = i + 1 ;
4097 const isHighlighted = highlightStart !== undefined
4198 && highlightEnd !== undefined
4299 && lineNum >= highlightStart
43100 && lineNum <= highlightEnd ;
44101 const isScrollTarget = lineNum === scrollToLine ;
102+ const lineDiagnostics = diagnosticsByLine ?. get ( lineNum ) ;
103+
45104 return (
46105 < div
47106 key = { `${ lineNum } -${ navCounter } ` }
@@ -54,14 +113,98 @@ export function CodeViewer({
54113 onDoubleClick = { onLineDoubleClick ? ( ) => onLineDoubleClick ( lineNum ) : undefined }
55114 >
56115 < span style = { { ...LINE_NUM_STYLE , width : gutterWidth } } > { lineNum } </ span >
57- < span style = { LINE_CONTENT_STYLE } > { line } </ span >
116+ < span style = { LINE_CONTENT_STYLE } >
117+ { lineDiagnostics
118+ ? renderWithDiagnostics ( lineTokens , lineDiagnostics )
119+ : renderTokens ( lineTokens )
120+ }
121+ </ span >
58122 </ div >
59123 ) ;
60124 } ) }
61125 </ div >
62126 ) ;
63127}
64128
129+ function renderTokens ( tokens : Token [ ] ) : React . ReactNode {
130+ return tokens . map ( ( token , j ) => {
131+ const color = token . type ? TOKEN_COLORS [ token . type ] : undefined ;
132+ return color
133+ ? < span key = { j } style = { { color } } > { token . content } </ span >
134+ : < span key = { j } > { token . content } </ span > ;
135+ } ) ;
136+ }
137+
138+ function renderWithDiagnostics ( tokens : Token [ ] , diagnostics : Diagnostic [ ] ) : React . ReactNode {
139+ let col = 1 ;
140+ const elements : React . ReactNode [ ] = [ ] ;
141+ let key = 0 ;
142+
143+ for ( const token of tokens ) {
144+ const tokenStart = col ;
145+ const tokenEnd = col + token . content . length ;
146+ const color = token . type ? TOKEN_COLORS [ token . type ] : undefined ;
147+
148+ let hasOverlap = false ;
149+ for ( const d of diagnostics ) {
150+ const dEnd = d . endCol ?? 999 ;
151+ if ( d . startCol < tokenEnd && dEnd > tokenStart ) {
152+ hasOverlap = true ;
153+ break ;
154+ }
155+ }
156+
157+ if ( ! hasOverlap ) {
158+ elements . push (
159+ color
160+ ? < span key = { key ++ } style = { { color } } > { token . content } </ span >
161+ : < span key = { key ++ } > { token . content } </ span > ,
162+ ) ;
163+ } else {
164+ const chars = token . content ;
165+ let segStart = 0 ;
166+ let currentSeverity = '' ;
167+
168+ for ( let c = 0 ; c <= chars . length ; c ++ ) {
169+ const charCol = tokenStart + c ;
170+ let severity = '' ;
171+ for ( const d of diagnostics ) {
172+ const dEnd = d . endCol ?? 999 ;
173+ if ( charCol >= d . startCol && charCol < dEnd ) {
174+ severity = d . severity ;
175+ break ;
176+ }
177+ }
178+
179+ if ( c === chars . length || severity !== currentSeverity ) {
180+ if ( segStart < c ) {
181+ const text = chars . slice ( segStart , c ) ;
182+ if ( currentSeverity ) {
183+ elements . push (
184+ < span key = { key ++ } style = { { color, textDecoration : `underline wavy ${ SQUIGGLE_COLORS [ currentSeverity ] } ` , textUnderlineOffset : '3px' } } >
185+ { text }
186+ </ span > ,
187+ ) ;
188+ } else {
189+ elements . push (
190+ color
191+ ? < span key = { key ++ } style = { { color } } > { text } </ span >
192+ : < span key = { key ++ } > { text } </ span > ,
193+ ) ;
194+ }
195+ }
196+ segStart = c ;
197+ currentSeverity = severity ;
198+ }
199+ }
200+ }
201+
202+ col = tokenEnd ;
203+ }
204+
205+ return elements ;
206+ }
207+
65208const CONTAINER_STYLE : React . CSSProperties = {
66209 margin : 0 ,
67210 maxHeight : '100%' ,
0 commit comments