11import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react'
22import Editor , { type Monaco , type OnMount } from '@monaco-editor/react'
3+ type ITextModel = Monaco [ 'editor' ] [ 'ITextModel' ]
4+ type FindMatch = Monaco [ 'editor' ] [ 'FindMatch' ]
5+ type IModelDeltaDecoration = Monaco [ 'editor' ] [ 'IModelDeltaDecoration' ]
6+ type IEditorDecorationsCollection = Monaco [ 'editor' ] [ 'IEditorDecorationsCollection' ]
37import clsx from 'clsx'
48import XsdFeatures from 'monaco-xsd-code-completion/esm/XsdFeatures'
59import 'monaco-xsd-code-completion/src/style.css'
@@ -111,7 +115,6 @@ function mapToValidationErrors(rawErrors: readonly XMLValidationError[], model:
111115
112116 return rawErrors
113117 . map ( ( e ) => {
114- // Use the reported line number, capped to the model
115118 const lineNumber = Math . max ( 1 , Math . min ( e . loc ?. lineNumber ?? 1 , totalLines ) )
116119 const { startColumn, endColumn } = findErrorRange ( model . getLineContent ( lineNumber ) , e . message )
117120 return { message : e . message , lineNumber, startColumn, endColumn }
@@ -159,6 +162,72 @@ function isConfigurationFile(fileExtension: string) {
159162 return fileExtension === 'xml'
160163}
161164
165+ async function validateFlow ( content : string , model : ITextModel ) : Promise < ValidationError [ ] > {
166+ const flowFragment = extractFlowElements ( content )
167+ if ( ! flowFragment ) return [ ]
168+
169+ const wrapped = wrapFlowXml ( flowFragment )
170+ const startLine = findFlowElementsStartLine ( content )
171+
172+ const flowResult = await validateXML ( {
173+ xml : [ { fileName : 'flow.xml' , contents : wrapped } ] ,
174+ schema : [ { fileName : 'flowconfig.xsd' , contents : flowXsd } ] ,
175+ } )
176+
177+ return mapToValidationErrors ( flowResult . errors , model ) . map ( ( err ) => ( {
178+ ...err ,
179+ lineNumber : err . lineNumber + startLine ,
180+ startColumn : 1 ,
181+ endColumn : model . getLineLength ( err . lineNumber + startLine ) ,
182+ } ) )
183+ }
184+
185+ async function validateConfiguration ( content : string , xsd : string , model : ITextModel ) : Promise < ValidationError [ ] > {
186+ const result = await validateXML ( {
187+ xml : [ { fileName : 'config.xml' , contents : content } ] ,
188+ schema : [ { fileName : 'FrankConfig.xsd' , contents : xsd } ] ,
189+ } )
190+
191+ if ( ! result . valid && result . errors . length === 0 ) {
192+ return [ notWellFormedError ( model ) ]
193+ }
194+
195+ const filtered = result . errors . filter (
196+ ( e ) => ! e . message . includes ( '{urn:frank-flow}' ) && ! e . message . includes ( 'Skipping attribute use prohibition' ) ,
197+ )
198+
199+ return mapToValidationErrors ( filtered , model )
200+ }
201+
202+ /**
203+ * Maps a single Monaco regex match to decoration objects.
204+ */
205+ function mapMatchToDecorations ( match : FindMatch ) : IModelDeltaDecoration [ ] {
206+ const keyText = match . matches ! [ 1 ]
207+ const valueText = match . matches ! [ 3 ]
208+
209+ return [
210+ {
211+ range : {
212+ startLineNumber : match . range . startLineNumber ,
213+ startColumn : match . range . startColumn ,
214+ endLineNumber : match . range . startLineNumber ,
215+ endColumn : match . range . startColumn + keyText . length ,
216+ } ,
217+ options : { inlineClassName : 'monaco-flow-attribute' } ,
218+ } ,
219+ {
220+ range : {
221+ startLineNumber : match . range . startLineNumber ,
222+ startColumn : match . range . endColumn - valueText . length ,
223+ endLineNumber : match . range . startLineNumber ,
224+ endColumn : match . range . endColumn ,
225+ } ,
226+ options : { inlineClassName : 'monaco-flow-attribute-value' } ,
227+ } ,
228+ ]
229+ }
230+
162231export default function CodeEditor ( ) {
163232 const theme = useTheme ( )
164233 const project = useProjectStore . getState ( ) . project
@@ -173,6 +242,7 @@ export default function CodeEditor() {
173242 const monacoReference = useRef < Monaco | null > ( null )
174243 const xsdContentRef = useRef < string | null > ( null )
175244 const errorDecorationsRef = useRef < { clear : ( ) => void } | null > ( null )
245+ const flowDecorationsRef = useRef < IEditorDecorationsCollection | null > ( null )
176246 const debounceTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
177247 const savedTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
178248 const validationTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
@@ -195,6 +265,30 @@ export default function CodeEditor() {
195265
196266 const isDiffTab = activeTab . type === 'diff'
197267
268+ const applyFlowHighlighter = useCallback ( ( ) => {
269+ const editor = editorReference . current
270+ const model = editor ?. getModel ( )
271+
272+ if ( ! editor || ! model || fileLanguage !== 'xml' ) return
273+
274+ const matches = model . findMatches (
275+ String . raw `\b(xmlns:flow|flow:[\w-]+)(\s*=\s*)("[^"]*"|'[^']*')` ,
276+ false ,
277+ true ,
278+ false ,
279+ null ,
280+ true ,
281+ )
282+
283+ const decorations = matches . flatMap ( ( match ) => mapMatchToDecorations ( match ) )
284+
285+ if ( flowDecorationsRef . current ) {
286+ flowDecorationsRef . current . set ( decorations )
287+ } else {
288+ flowDecorationsRef . current = editor . createDecorationsCollection ( decorations )
289+ }
290+ } , [ fileLanguage ] )
291+
198292 const performSave = useCallback (
199293 ( content ?: string ) => {
200294 if ( ! project || ! activeTabFilePath || isDiffTab ) return
@@ -292,64 +386,27 @@ export default function CodeEditor() {
292386
293387 const runSchemaValidation = useCallback (
294388 async ( content : string ) => {
295- const monaco = monacoReference . current
296389 const editor = editorReference . current
297390 const xsdContent = xsdContentRef . current
298- if ( ! monaco || ! editor || ! xsdContent ) return
391+ if ( ! editor || ! xsdContent ) return
299392
300393 const validationId = ++ validationCounterRef . current
394+ const model = editor . getModel ( ) as ITextModel
395+ if ( ! model ) return
301396
302397 try {
303- const model = editor . getModel ( )
304- if ( ! model ) return
305-
306- const flowFragment = extractFlowElements ( content )
307- let flowErrors : ValidationError [ ] = [ ]
308-
309- if ( flowFragment ) {
310- const wrapped = wrapFlowXml ( flowFragment )
311- const startLine = findFlowElementsStartLine ( content )
312-
313- const flowResult = await validateXML ( {
314- xml : [ { fileName : 'flow.xml' , contents : wrapped } ] ,
315- schema : [ { fileName : 'flowconfig.xsd' , contents : flowXsd } ] ,
316- } )
317-
318- // Map errors and offset the line numbers
319- flowErrors = mapToValidationErrors ( flowResult . errors , model ) . map ( ( errorInformation ) => ( {
320- ...errorInformation ,
321- lineNumber : errorInformation . lineNumber + startLine , // shift relative to full file
322- startColumn : 1 ,
323- endColumn : model . getLineLength ( errorInformation . lineNumber + startLine ) ,
324- } ) )
325- }
326- const result = await validateXML ( {
327- xml : [ { fileName : 'config.xml' , contents : content } ] ,
328- schema : [ { fileName : 'FrankConfig.xsd' , contents : xsdContent } ] ,
329- } )
398+ const [ flowErrors , frankErrors ] = await Promise . all ( [
399+ validateFlow ( content , model ) ,
400+ validateConfiguration ( content , xsdContent , model ) ,
401+ ] )
330402
331403 if ( validationId !== validationCounterRef . current ) return
332404
333- if ( ! result . valid && result . errors . length === 0 ) {
334- applyValidationDecorations ( [ notWellFormedError ( model ) ] )
335- return
336- }
337-
338- // Filter out errors mentioning the flow namespace
339- const filteredErrors = result . errors . filter (
340- ( error ) =>
341- ! error . message . includes ( '{urn:frank-flow}' ) &&
342- ! error . message . includes ( 'Skipping attribute use prohibition' ) , // This gets prompted by flowelements being present in the xml, harmless so we filter it out
343- )
344- const frankErrors = mapToValidationErrors ( filteredErrors , model )
345-
346- // Then merge the FlowErrors and the FrankErrors
347405 applyValidationDecorations ( [ ...frankErrors , ...flowErrors ] )
348406 } catch {
349- if ( validationId !== validationCounterRef . current ) return
350- const model = editor . getModel ( )
351- if ( ! model ) return
352- applyValidationDecorations ( [ notWellFormedError ( model ) ] )
407+ if ( validationId === validationCounterRef . current ) {
408+ applyValidationDecorations ( [ notWellFormedError ( model ) ] )
409+ }
353410 }
354411 } ,
355412 [ applyValidationDecorations ] ,
@@ -391,6 +448,8 @@ export default function CodeEditor() {
391448 monacoReference . current = monacoInstance
392449 setEditorMounted ( true )
393450
451+ applyFlowHighlighter ( )
452+
394453 editor . addAction ( {
395454 id : 'save-file' ,
396455 label : 'Save File' ,
@@ -428,7 +487,6 @@ export default function CodeEditor() {
428487 return useEditorTabStore . subscribe (
429488 ( state ) => state . activeTabFilePath ,
430489 ( newActiveTab , oldActiveTab ) => {
431- if ( oldActiveTab && oldActiveTab !== newActiveTab ) flushPendingSave ( )
432490 if ( oldActiveTab && oldActiveTab !== newActiveTab ) {
433491 const currentEditor = editorReference . current
434492 if ( currentEditor ) {
@@ -501,6 +559,10 @@ export default function CodeEditor() {
501559 errorDecorationsRef . current . clear ( )
502560 errorDecorationsRef . current = null
503561 }
562+ // Also clear flow decorations when switching files
563+ if ( flowDecorationsRef . current ) {
564+ flowDecorationsRef . current . set ( [ ] )
565+ }
504566 const monaco = monacoReference . current
505567 const editor = editorReference . current
506568 if ( monaco && editor ) {
@@ -512,7 +574,8 @@ export default function CodeEditor() {
512574 useEffect ( ( ) => {
513575 if ( ! fileContent || ! xsdLoaded || isDiffTab || fileLanguage !== 'xml' ) return
514576 runSchemaValidation ( fileContent )
515- } , [ fileContent , xsdLoaded , isDiffTab , runSchemaValidation , fileLanguage ] )
577+ applyFlowHighlighter ( ) // Refresh highlighter when schema is loaded or content changes
578+ } , [ fileContent , xsdLoaded , isDiffTab , runSchemaValidation , fileLanguage , applyFlowHighlighter ] )
516579
517580 useEffect ( ( ) => {
518581 if ( ! fileContent || ! activeTabFilePath || ! editorReference . current || isDiffTab ) return
@@ -635,12 +698,15 @@ export default function CodeEditor() {
635698 < div className = "h-full" >
636699 < Editor
637700 language = { fileLanguage }
638- theme = { ` vs-${ theme } ` }
701+ theme = { theme === 'dark' ? ' vs-dark' : 'vs' }
639702 value = { fileContent }
640703 onMount = { handleEditorMount }
641704 onChange = { ( value ) => {
642705 scheduleSave ( )
643- if ( value && fileLanguage === 'xml' ) scheduleSchemaValidation ( value )
706+ if ( value && fileLanguage === 'xml' ) {
707+ scheduleSchemaValidation ( value )
708+ applyFlowHighlighter ( ) // Real-time highlight updates
709+ }
644710 } }
645711 options = { { automaticLayout : true , quickSuggestions : false } }
646712 />
0 commit comments