Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/main/frontend/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@ body {
background: rgba(34, 197, 94, 0.08) !important;
}

.monaco-flow-attribute {
color: #a8a8a8 !important;
font-style: italic !important;
}

.monaco-flow-attribute-value {
color: #a8a8a8 !important;
font-style: italic !important;
}

.dark .monaco-flow-attribute,
.dark .monaco-flow-attribute-value {
color: #808080 !important;
}

:root {
/* Allotment Styling */
--focus-border: var(--color-brand);
Expand Down
171 changes: 119 additions & 52 deletions src/main/frontend/app/routes/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import XsdFeatures from 'monaco-xsd-code-completion/esm/XsdFeatures'
import 'monaco-xsd-code-completion/src/style.css'
import XsdManager from 'monaco-xsd-code-completion/esm/XsdManager'
import * as monaco from 'monaco-editor'
import { useCallback, useEffect, useRef, useState } from 'react'
import { validateXML, type XMLValidationError } from 'xmllint-wasm'
import { useShallow } from 'zustand/react/shallow'
Expand Down Expand Up @@ -111,7 +112,6 @@

return rawErrors
.map((e) => {
// Use the reported line number, capped to the model
const lineNumber = Math.max(1, Math.min(e.loc?.lineNumber ?? 1, totalLines))
const { startColumn, endColumn } = findErrorRange(model.getLineContent(lineNumber), e.message)
return { message: e.message, lineNumber, startColumn, endColumn }
Expand Down Expand Up @@ -159,7 +159,77 @@
return fileExtension === 'xml'
}

async function validateFlow(content: string, model: monaco.editor.ITextModel): Promise<ValidationError[]> {
const flowFragment = extractFlowElements(content)
if (!flowFragment) return []

const wrapped = wrapFlowXml(flowFragment)
const startLine = findFlowElementsStartLine(content)

const flowResult = await validateXML({
xml: [{ fileName: 'flow.xml', contents: wrapped }],
schema: [{ fileName: 'flowconfig.xsd', contents: flowXsd }],
})

return mapToValidationErrors(flowResult.errors, model).map((err) => ({
...err,
lineNumber: err.lineNumber + startLine,
startColumn: 1,
endColumn: model.getLineLength(err.lineNumber + startLine),
}))
}

async function validateFrank(
Copy link
Copy Markdown
Member

@philipsens philipsens Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateConfiguration

The term frank is wrongfully used.

content: string,
xsd: string,
model: monaco.editor.ITextModel,
): Promise<ValidationError[]> {
const result = await validateXML({
xml: [{ fileName: 'config.xml', contents: content }],
schema: [{ fileName: 'FrankConfig.xsd', contents: xsd }],
})

if (!result.valid && result.errors.length === 0) {
return [notWellFormedError(model)]
}

const filtered = result.errors.filter(
(e) => !e.message.includes('{urn:frank-flow}') && !e.message.includes('Skipping attribute use prohibition'),
)

return mapToValidationErrors(filtered, model)
}

/**
* Maps a single Monaco regex match to decoration objects.
*/
function mapMatchToDecorations(match: monaco.editor.FindMatch): monaco.editor.IModelDeltaDecoration[] {
const keyText = match.matches![1]
const valueText = match.matches![3]

return [
{
range: {
startLineNumber: match.range.startLineNumber,
startColumn: match.range.startColumn,
endLineNumber: match.range.startLineNumber,
endColumn: match.range.startColumn + keyText.length,
},
options: { inlineClassName: 'monaco-flow-attribute' },
},
{
range: {
startLineNumber: match.range.startLineNumber,
startColumn: match.range.endColumn - valueText.length,
endLineNumber: match.range.startLineNumber,
endColumn: match.range.endColumn,
},
options: { inlineClassName: 'monaco-flow-attribute-value' },
},
]
}

export default function CodeEditor() {

Check warning on line 232 in src/main/frontend/app/routes/editor/editor.tsx

View workflow job for this annotation

GitHub Actions / Build & Run All Tests

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed
const theme = useTheme()
const project = useProjectStore.getState().project
const [activeTabFilePath, setActiveTabFilePath] = useState<string>(useEditorTabStore.getState().activeTabFilePath)
Expand All @@ -173,6 +243,7 @@
const monacoReference = useRef<Monaco | null>(null)
const xsdContentRef = useRef<string | null>(null)
const errorDecorationsRef = useRef<{ clear: () => void } | null>(null)
const flowDecorationsRef = useRef<monaco.editor.IEditorDecorationsCollection | null>(null)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
Expand All @@ -195,6 +266,30 @@

const isDiffTab = activeTab.type === 'diff'

const applyFlowHighlighter = useCallback(() => {
const editor = editorReference.current
const model = editor?.getModel()

if (!editor || !model || fileLanguage !== 'xml') return

const matches = model.findMatches(
String.raw`\b(xmlns:flow|flow:[\w-]+)(\s*=\s*)("[^"]*"|'[^']*')`,
false,
true,
false,
null,
true,
)

const decorations = matches.flatMap((match) => mapMatchToDecorations(match))

if (flowDecorationsRef.current) {
flowDecorationsRef.current.set(decorations)
} else {
flowDecorationsRef.current = editor.createDecorationsCollection(decorations)
}
}, [fileLanguage])

const performSave = useCallback(
(content?: string) => {
if (!project || !activeTabFilePath || isDiffTab) return
Expand Down Expand Up @@ -292,64 +387,27 @@

const runSchemaValidation = useCallback(
async (content: string) => {
const monaco = monacoReference.current
const editor = editorReference.current
const xsdContent = xsdContentRef.current
if (!monaco || !editor || !xsdContent) return
if (!editor || !xsdContent) return

const validationId = ++validationCounterRef.current
const model = editor.getModel() as monaco.editor.ITextModel
if (!model) return

try {
const model = editor.getModel()
if (!model) return

const flowFragment = extractFlowElements(content)
let flowErrors: ValidationError[] = []

if (flowFragment) {
const wrapped = wrapFlowXml(flowFragment)
const startLine = findFlowElementsStartLine(content)

const flowResult = await validateXML({
xml: [{ fileName: 'flow.xml', contents: wrapped }],
schema: [{ fileName: 'flowconfig.xsd', contents: flowXsd }],
})

// Map errors and offset the line numbers
flowErrors = mapToValidationErrors(flowResult.errors, model).map((errorInformation) => ({
...errorInformation,
lineNumber: errorInformation.lineNumber + startLine, // shift relative to full file
startColumn: 1,
endColumn: model.getLineLength(errorInformation.lineNumber + startLine),
}))
}
const result = await validateXML({
xml: [{ fileName: 'config.xml', contents: content }],
schema: [{ fileName: 'FrankConfig.xsd', contents: xsdContent }],
})
const [flowErrors, frankErrors] = await Promise.all([
validateFlow(content, model),
validateFrank(content, xsdContent, model),
])

if (validationId !== validationCounterRef.current) return

if (!result.valid && result.errors.length === 0) {
applyValidationDecorations([notWellFormedError(model)])
return
}

// Filter out errors mentioning the flow namespace
const filteredErrors = result.errors.filter(
(error) =>
!error.message.includes('{urn:frank-flow}') &&
!error.message.includes('Skipping attribute use prohibition'), // This gets prompted by flowelements being present in the xml, harmless so we filter it out
)
const frankErrors = mapToValidationErrors(filteredErrors, model)

// Then merge the FlowErrors and the FrankErrors
applyValidationDecorations([...frankErrors, ...flowErrors])
} catch {
if (validationId !== validationCounterRef.current) return
const model = editor.getModel()
if (!model) return
applyValidationDecorations([notWellFormedError(model)])
if (validationId === validationCounterRef.current) {
applyValidationDecorations([notWellFormedError(model)])
}
}
},
[applyValidationDecorations],
Expand Down Expand Up @@ -391,6 +449,8 @@
monacoReference.current = monacoInstance
setEditorMounted(true)

applyFlowHighlighter()

editor.addAction({
id: 'save-file',
label: 'Save File',
Expand Down Expand Up @@ -428,7 +488,6 @@
return useEditorTabStore.subscribe(
(state) => state.activeTabFilePath,
(newActiveTab, oldActiveTab) => {
if (oldActiveTab && oldActiveTab !== newActiveTab) flushPendingSave()
if (oldActiveTab && oldActiveTab !== newActiveTab) {
const currentEditor = editorReference.current
if (currentEditor) {
Expand Down Expand Up @@ -501,6 +560,10 @@
errorDecorationsRef.current.clear()
errorDecorationsRef.current = null
}
// Also clear flow decorations when switching files
if (flowDecorationsRef.current) {
flowDecorationsRef.current.set([])
}
const monaco = monacoReference.current
const editor = editorReference.current
if (monaco && editor) {
Expand All @@ -512,7 +575,8 @@
useEffect(() => {
if (!fileContent || !xsdLoaded || isDiffTab || fileLanguage !== 'xml') return
runSchemaValidation(fileContent)
}, [fileContent, xsdLoaded, isDiffTab, runSchemaValidation, fileLanguage])
applyFlowHighlighter() // Refresh highlighter when schema is loaded or content changes
}, [fileContent, xsdLoaded, isDiffTab, runSchemaValidation, fileLanguage, applyFlowHighlighter])

useEffect(() => {
if (!fileContent || !activeTabFilePath || !editorReference.current || isDiffTab) return
Expand Down Expand Up @@ -635,12 +699,15 @@
<div className="h-full">
<Editor
language={fileLanguage}
theme={`vs-${theme}`}
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
value={fileContent}
onMount={handleEditorMount}
onChange={(value) => {
scheduleSave()
if (value && fileLanguage === 'xml') scheduleSchemaValidation(value)
if (value && fileLanguage === 'xml') {
scheduleSchemaValidation(value)
applyFlowHighlighter() // Real-time highlight updates
}
}}
options={{ automaticLayout: true, quickSuggestions: false }}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/main/frontend/app/routes/studio/canvas/flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@
showErrorToast(`Failed to save XML: ${error instanceof Error ? error.message : error}`)
setSaveStatus('idle')
}
}, [project])

Check warning on line 186 in src/main/frontend/app/routes/studio/canvas/flow.tsx

View workflow job for this annotation

GitHub Actions / Build & Run All Tests

React Hook useCallback has an unnecessary dependency: 'project'. Either exclude it or remove the dependency array

const autosaveEnabled = useSettingsStore((s) => s.general.autoSave.enabled)
const autosaveDelay = useSettingsStore((s) => s.general.autoSave.delayMs)
Expand Down Expand Up @@ -591,7 +591,7 @@
addNodeAtPosition(position, parsedData.name)
}

const onDragEnd = (event: React.DragEvent) => {
const onDragEnd = () => {
setDraggedName(null)
setParentId(null)
}
Expand Down
1 change: 1 addition & 0 deletions src/main/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"dagre": "^0.8.5",
"dotenv": "^17.3.1",
"isbot": "^5.1.36",
"monaco-editor": "^0.55.1",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@monaco-editor/react brings monaco-editor with it as a dependency, there's no need to add it as a package on its own as well

"monaco-xsd-code-completion": "^1.0.0",
"react": "^19.2.4",
"react-complex-tree": "^2.6.1",
Expand Down
Loading