Skip to content

Commit 83ddbd4

Browse files
Applied custom editor color rules (#384)
* Add flow validation and highlighting support in the editor * Refactor editor to use Monaco react type definitions for improved type safety
1 parent 70fbc26 commit 83ddbd4

4 files changed

Lines changed: 135 additions & 53 deletions

File tree

pnpm-lock.yaml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/frontend/app/app.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,21 @@ body {
192192
background: rgba(34, 197, 94, 0.08) !important;
193193
}
194194

195+
.monaco-flow-attribute {
196+
color: #a8a8a8 !important;
197+
font-style: italic !important;
198+
}
199+
200+
.monaco-flow-attribute-value {
201+
color: #a8a8a8 !important;
202+
font-style: italic !important;
203+
}
204+
205+
.dark .monaco-flow-attribute,
206+
.dark .monaco-flow-attribute-value {
207+
color: #808080 !important;
208+
}
209+
195210
:root {
196211
/* Allotment Styling */
197212
--focus-border: var(--color-brand);

src/main/frontend/app/routes/editor/editor.tsx

Lines changed: 118 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react'
22
import 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']
37
import clsx from 'clsx'
48
import XsdFeatures from 'monaco-xsd-code-completion/esm/XsdFeatures'
59
import '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+
162231
export 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
/>

src/main/frontend/app/routes/studio/canvas/flow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ function FlowCanvas() {
591591
addNodeAtPosition(position, parsedData.name)
592592
}
593593

594-
const onDragEnd = (event: React.DragEvent) => {
594+
const onDragEnd = () => {
595595
setDraggedName(null)
596596
setParentId(null)
597597
}

0 commit comments

Comments
 (0)