diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f953354..4bfa0daa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: isbot: specifier: ^5.1.36 version: 5.1.36 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 monaco-xsd-code-completion: specifier: ^1.0.0 version: 1.0.0 @@ -1657,6 +1660,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xyflow/react@12.10.1': resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==} diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index 0a3a4fb0..efa4ee6e 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -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); diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 6962942b..b446043f 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' 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' @@ -111,7 +112,6 @@ function mapToValidationErrors(rawErrors: readonly XMLValidationError[], model: 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 } @@ -159,6 +159,76 @@ function isConfigurationFile(fileExtension: string) { return fileExtension === 'xml' } +async function validateFlow(content: string, model: monaco.editor.ITextModel): Promise { + 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( + content: string, + xsd: string, + model: monaco.editor.ITextModel, +): Promise { + 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() { const theme = useTheme() const project = useProjectStore.getState().project @@ -173,6 +243,7 @@ export default function CodeEditor() { const monacoReference = useRef(null) const xsdContentRef = useRef(null) const errorDecorationsRef = useRef<{ clear: () => void } | null>(null) + const flowDecorationsRef = useRef(null) const debounceTimerRef = useRef | null>(null) const savedTimerRef = useRef | null>(null) const validationTimerRef = useRef | null>(null) @@ -195,6 +266,30 @@ export default function CodeEditor() { 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 @@ -292,64 +387,27 @@ export default function CodeEditor() { 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], @@ -391,6 +449,8 @@ export default function CodeEditor() { monacoReference.current = monacoInstance setEditorMounted(true) + applyFlowHighlighter() + editor.addAction({ id: 'save-file', label: 'Save File', @@ -428,7 +488,6 @@ export default function CodeEditor() { return useEditorTabStore.subscribe( (state) => state.activeTabFilePath, (newActiveTab, oldActiveTab) => { - if (oldActiveTab && oldActiveTab !== newActiveTab) flushPendingSave() if (oldActiveTab && oldActiveTab !== newActiveTab) { const currentEditor = editorReference.current if (currentEditor) { @@ -501,6 +560,10 @@ export default function CodeEditor() { 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) { @@ -512,7 +575,8 @@ export default function CodeEditor() { 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 @@ -635,12 +699,15 @@ export default function CodeEditor() {
{ scheduleSave() - if (value && fileLanguage === 'xml') scheduleSchemaValidation(value) + if (value && fileLanguage === 'xml') { + scheduleSchemaValidation(value) + applyFlowHighlighter() // Real-time highlight updates + } }} options={{ automaticLayout: true, quickSuggestions: false }} /> diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index a50d5fc4..52ceb3b7 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -591,7 +591,7 @@ function FlowCanvas() { addNodeAtPosition(position, parsedData.name) } - const onDragEnd = (event: React.DragEvent) => { + const onDragEnd = () => { setDraggedName(null) setParentId(null) } diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json index e4f2d532..54c3ba45 100644 --- a/src/main/frontend/package.json +++ b/src/main/frontend/package.json @@ -26,6 +26,7 @@ "dagre": "^0.8.5", "dotenv": "^17.3.1", "isbot": "^5.1.36", + "monaco-editor": "^0.55.1", "monaco-xsd-code-completion": "^1.0.0", "react": "^19.2.4", "react-complex-tree": "^2.6.1",