This is an automated email from the ASF dual-hosted git repository.

mgubaidullin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-karavan.git

commit d94b25f12f0b7df68e3b7d76e0f986fb7bb0443c
Author: Marat Gubaidullin <[email protected]>
AuthorDate: Mon Aug 24 17:59:05 2026 -0400

    Karavan-app UI Page Developer
---
 .../src/main/webui/src/ui/developer/CodeEditor.tsx |  51 +++++
 .../main/webui/src/ui/developer/DesignerEditor.tsx | 134 +++++++++++++
 .../webui/src/ui/developer/DeveloperEditor.tsx     |  74 +++++++
 .../webui/src/ui/developer/DeveloperManager.css    | 221 +++++++++++++++++++++
 .../webui/src/ui/developer/DeveloperManager.tsx    |  23 +++
 .../src/ui/developer/DeveloperManagerImpl.tsx      |  86 ++++++++
 .../webui/src/ui/developer/DeveloperToolbar.css    |   4 +
 .../webui/src/ui/developer/DeveloperToolbar.tsx    |  32 +++
 .../main/webui/src/ui/developer/MarkdownEditor.tsx |  69 +++++++
 .../webui/src/ui/developer/diff/DiffViewer.css     |   4 +
 .../webui/src/ui/developer/diff/DiffViewer.tsx     |  59 ++++++
 .../webui/src/ui/developer/groovy/GroovyEditor.tsx |  58 ++++++
 .../webui/src/ui/developer/monaco/EditorConfig.ts  |  26 +++
 .../monaco/EditorErrorBoundaryWrapper.tsx          |  66 ++++++
 .../ui/developer/monaco/EditorOverlayWidget.css    |  12 ++
 .../ui/developer/monaco/EditorOverlayWidget.tsx    |  33 +++
 .../src/ui/developer/monaco/EditorToolbar.tsx      |  32 +++
 .../ui/developer/monaco/MonacoEditorWrapper.tsx    |  23 +++
 .../developer/monaco/MonacoEditorWrapperImpl.tsx   | 221 +++++++++++++++++++++
 .../monaco/MonacoEditorWrapperStateless.tsx        |  99 +++++++++
 .../src/ui/developer/toggle/DeveloperToggle.tsx    |  76 +++++++
 .../ui/developer/toggle/useDeveloperToggleStore.ts |  38 ++++
 22 files changed, 1441 insertions(+)

diff --git a/karavan-app/src/main/webui/src/ui/developer/CodeEditor.tsx 
b/karavan-app/src/main/webui/src/ui/developer/CodeEditor.tsx
new file mode 100644
index 00000000..4f579246
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/CodeEditor.tsx
@@ -0,0 +1,51 @@
+import React, {useMemo} from 'react';
+import {defaultEditorOptions} from "@developer/monaco/EditorConfig";
+import {MonacoEditorWrapper} from "./monaco/MonacoEditorWrapper";
+import {Group, Panel} from "react-resizable-panels";
+import type * as monaco from "monaco-editor";
+
+const LANG_MAP: Record<string, string> = {
+    sh: "shell",
+    md: "markdown",
+    properties: "ini",
+    groovy: "java",
+    yml: "yaml",
+    json: "json",
+    xml: "xml",
+    xslt: "xml",
+    wsdl: "xml",
+};
+
+export interface CodeEditorProps {
+    projectId?: string;
+    filename: string;
+    initialCode?: string;
+    hideMinimap?: boolean;
+    language?: string;
+    onChange?: (value: (string | undefined)) => void;
+    onLinkOpen?: (uri: monaco.Uri) => void;
+}
+
+export function CodeEditor(props: CodeEditorProps) {
+
+    const {projectId, filename, initialCode, onChange, language, onLinkOpen, 
hideMinimap} = props;
+
+    const lang = language ?? useMemo(() => {
+        const ext = filename?.split(".").pop()?.toLowerCase();
+        return (ext && (LANG_MAP[ext] ?? ext)) || "text";
+    }, [filename]);
+
+    return (
+        <Group orientation="horizontal" className='editor-with-preview' 
style={{paddingTop: 6}}>
+            <Panel minSize={100} className='editor-panel'>
+                <MonacoEditorWrapper key={`${projectId}/${filename}`}
+                                     language={lang ?? 'text'}
+                                     editorOptions={{...defaultEditorOptions, 
minimap: {enabled: !hideMinimap}}}
+                                     initialCode={initialCode}
+                                     onLinkOpen={onLinkOpen}
+                                     onChange={onChange}
+                />
+            </Panel>
+        </Group>
+    )
+}
diff --git a/karavan-app/src/main/webui/src/ui/developer/DesignerEditor.tsx 
b/karavan-app/src/main/webui/src/ui/developer/DesignerEditor.tsx
new file mode 100644
index 00000000..1adcdbc6
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/DesignerEditor.tsx
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, {useEffect, useState} from 'react';
+import '../designer/karavan.css';
+import {useFilesStore, useFileStore, useProjectStore} from 
"@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {useDesignerStore, useIntegrationStore} from "@designer/DesignerStore";
+import {BeanFactoryDefinition} from "@core/model/CamelDefinition";
+import {CodeUtils} from "@utils/CodeUtils";
+import {ProjectService} from "@services/ProjectService";
+import {APPLICATION_PROPERTIES, ProjectFile} from "@models/ProjectModels";
+import {CamelUi} from "@designer/utils/CamelUi";
+import {KaravanDesigner} from "@designer/KaravanDesigner";
+import {IntegrationFile} from "@core/model/IntegrationDefinition";
+
+export function DesignerEditor() {
+
+    const [project] = useProjectStore((s) => [s.project], shallow);
+    const [file, designerTab, setFile] = useFileStore((s) => [s.file, 
s.designerTab, s.setFile], shallow)
+    const [files] = useFilesStore((s) => [s.files], shallow);
+    const [key, setKey] = useIntegrationStore((s) => [s.key, s.setKey], 
shallow);
+    const [setSelectedStep] = useDesignerStore((s) => [s.setSelectedStep], 
shallow)
+    const [propertyPlaceholders, setPropertyPlaceholders] = useState<[string, 
string][]>([]);
+    const [beans, setBeans] = useState<BeanFactoryDefinition[]>([]);
+    const [code, setCode] = useState<string>();
+
+    useEffect(() => {
+        setCode(file?.code);
+        const pp = CodeUtils.getPropertyPlaceholders(files);
+        setPropertyPlaceholders(pp);
+        const bs = CodeUtils.getBeans(files);
+        setBeans(bs)
+        setKey(Math.random().toString());
+        return () => {
+            //save custom kamelet on page unload
+            if (project?.projectId?.includes('kamelets') && file) {
+                ProjectService.loadCustomKamelets()
+            }
+        };
+    }, []);
+
+    useEffect(() => {
+        // TODO: cause multiple KaravanDesigner reload
+        setKey(Math.random().toString());
+    }, [beans, propertyPlaceholders]);
+
+    function save(name: string, code: string) {
+        if (file) {
+            file.code = code;
+            ProjectService.updateFile(file, true);
+        }
+    }
+
+    function onGetCustomCode(name: string, javaType: string): Promise<string | 
undefined> {
+        return new Promise<string | undefined>(resolve => 
resolve(files.filter(f => f.name === name + ".java")?.at(0)?.code));
+    }
+
+    function onSavePropertyPlaceholder(key: string, value: string) {
+        const file = files.filter(f => f.name === 
APPLICATION_PROPERTIES)?.at(0);
+        const code = 
file?.code?.concat('\n').concat(key).concat('=').concat(value);
+        if (file && code) {
+            file.code = code;
+            ProjectService.updateFile(file, false);
+        }
+    }
+
+    function onCreateNewRoute(componentName: string, propertyName: string, 
propertyValue: string) {
+        const parameters: any = {};
+        parameters[propertyName] = propertyValue;
+        CamelUi.onCreateNewRoute(project.projectId, undefined, files, 
componentName, parameters, '', newFile => {
+            setFile('select', newFile);
+            setKey(Math.random().toString());
+        })
+    }
+
+    function onCreateNewFile(fileName: string, code: string, open:boolean) {
+        CamelUi.createNewFile(project.projectId, fileName, code, newFile => {
+            if (open) {
+                setFile('select', newFile);
+                setKey(Math.random().toString());
+            }
+        })
+    }
+
+    function internalConsumerClick(uri?: string, name?: string, routeId?: 
string, fileName?: string) {
+        CamelUi.internalConsumerClick(files, uri, name, routeId, fileName, 
(fileName1, step) => {
+            switchToFile(fileName1);
+            if (step) setSelectedStep(step);
+        })
+    }
+
+    function switchToFile(fileName: string) {
+        const file = files.filter(f => f.name === fileName).at(0);
+        if (file) {
+            setFile('select', file);
+            setKey(Math.random().toString())
+        }
+    }
+
+    return (file !== undefined && key !== undefined ?
+            <KaravanDesigner key={key}
+                             showCodeTab={true}
+                             filename={file.name}
+                             yaml={file.code}
+                             tab={designerTab}
+                             onSave={(name, yaml) => save(name, yaml)}
+                             onSaveCustomCode={(name, code, active) =>
+                                 ProjectService.updateFile(new 
ProjectFile(name + ".java", project.projectId, code, Date.now()), active)}
+                             onGetCustomCode={onGetCustomCode}
+                             propertyPlaceholders={propertyPlaceholders}
+                             
onSavePropertyPlaceholder={onSavePropertyPlaceholder}
+                             beans={beans}
+                             onInternalConsumerClick={internalConsumerClick}
+                             onCreateNewRoute={onCreateNewRoute}
+                             onCreateNewFile={onCreateNewFile}
+                             files={files.map(f => new IntegrationFile(f.name, 
f.code))}
+            />
+            : <></>
+    )
+}
diff --git a/karavan-app/src/main/webui/src/ui/developer/DeveloperEditor.tsx 
b/karavan-app/src/main/webui/src/ui/developer/DeveloperEditor.tsx
new file mode 100644
index 00000000..b12797a5
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/DeveloperEditor.tsx
@@ -0,0 +1,74 @@
+import React, {JSX, useEffect, useState} from 'react';
+import {CodeEditor} from "./CodeEditor";
+import {MarkdownEditor} from "./MarkdownEditor";
+import './DeveloperManager.css'
+import {EditorErrorBoundaryWrapper} from 
"@developer/monaco/EditorErrorBoundaryWrapper";
+import {GroovyEditor} from "./groovy/GroovyEditor";
+import {EditorType} from "@developer/monaco/EditorConfig";
+import {useFilesStore, useFileStore} from "@stores/ProjectStore";
+import {JSON_SCHEMA_EXTENSION} from "@core/contants";
+import {useNavigate} from "react-router-dom";
+import {useDebounceCallback} from "usehooks-ts";
+import {useDeveloperToggleStore} from 
"@developer/toggle/useDeveloperToggleStore";
+
+export interface DeveloperEditorProps {
+    editorType: EditorType;
+}
+
+export function DeveloperEditor(props: DeveloperEditorProps): JSX.Element {
+
+    const {editorType} = props
+    const setDeveloperView = useDeveloperToggleStore((s) => s.setDeveloperView)
+    const file = useFileStore((s) => s.file)
+    const setFile = useFileStore((s) => s.setFile)
+    const files = useFilesStore((s) => s.files);
+    const backgroundSaveFile = useFilesStore((s) => s.backgroundSaveFile);
+    const [code, setCode] = useState<string>(file?.code ?? "");
+    const isJsonSchema = file !== undefined && 
file.name.endsWith(JSON_SCHEMA_EXTENSION);
+    const navigate = useNavigate();
+
+    useEffect(() => {
+        setDeveloperView('code');
+    }, [setDeveloperView]);
+
+    useEffect(() => {
+        if (file) {
+            setCode(file.code);
+        }
+    }, [file?.projectId, file?.name]);
+
+    // 2. Create a stable, debounced save function
+    const debouncedSave = useDebounceCallback((updatedFile) => {
+        backgroundSaveFile(updatedFile);
+    }, 500); // 500ms delay after user STOPS typing
+
+    // 3. Handle actual user typing
+    function handleCodeChange(newCode: string | undefined) {
+        const safeCode = newCode ?? '';
+        setCode(safeCode); // Update UI instantly
+
+        if (file && file.code !== safeCode) {
+            // Trigger the debounced save. Empty files (safeCode === "") are 
now perfectly fine!
+            const updatedFile = { ...file, code: safeCode };
+            debouncedSave(updatedFile);
+        }
+    }
+
+    function getEditor() {
+        if (editorType === 'groovy' && file) {
+            return <GroovyEditor processorName={"Script"} 
filename={file?.name} initialCode={code} onChange={handleCodeChange}/>
+        } else if (editorType === 'markdown' && file) {
+            return <MarkdownEditor projectId={file?.projectId} 
filename={file?.name} initialCode={code} onChange={handleCodeChange}/>
+        } else if (file) {
+            return <CodeEditor projectId={file?.projectId} 
filename={file?.name} initialCode={code} onChange={handleCodeChange}/>
+        }
+    }
+
+    return (
+        <div className='editor-manager'>
+            <EditorErrorBoundaryWrapper onError={error => 
console.error(error)}>
+                {getEditor()}
+            </EditorErrorBoundaryWrapper>
+        </div>
+    )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/developer/DeveloperManager.css 
b/karavan-app/src/main/webui/src/ui/developer/DeveloperManager.css
new file mode 100644
index 00000000..8c3d81a9
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/DeveloperManager.css
@@ -0,0 +1,221 @@
+.editor-manager {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    justify-content: flex-start;
+    overflow: hidden;
+    /*border: 1px solid var(--pf-t--global--border--color--default);*/
+
+    .editor-error {
+        height: 100%;
+        width: 100%;
+        display: flex;
+        flex-direction: column;
+        /*justify-content: flex-start;*/
+        gap: 8px;
+        section {
+            height: 100%;
+        }
+    }
+
+    .monaco-editor-expandable-section {
+        border: none;
+        border-radius: 0;
+
+        .pf-v6-c-card__header {
+            padding: 4px;
+            overflow: hidden;
+
+            .pf-v6-c-card__title-text {
+                text-wrap: nowrap;
+                width: fit-content;
+            }
+
+            .value-changed-label {
+                background-color: 
var(--pf-t--global--color--nonstatus--yellow--default);
+                color: var(--pf-t--color--gray--95);
+                transition: background-color 0.5s ease;
+            }
+            .value-exception-label {
+                background-color: 
var(--pf-t--global--icon--color--status--danger--default);
+                color: var(--pf-t--color--gray--10);
+                transition: background-color 0.5s ease;
+            }
+
+            .pf-v6-c-card__header-toggle {
+                align-items: center;
+                display: flex;
+                margin: 0;
+                .pf-v6-c-button {
+                    padding: 4px;
+                    min-width: 16px;
+                }
+            }
+
+            .pf-v6-c-card__actions {
+                margin: 0;
+                .marshal-selector {
+                    select {
+                        padding: 4px 15px 4px 8px;
+                    }
+                    .pf-v6-c-form-control__utilities {
+                        padding: 4px 0 0 0;
+                    }
+                }
+            }
+        }
+        .pf-v6-c-card__body {
+            padding: 0;
+        }
+    }
+    .monaco-editor-expandable-section::before {
+        display: none;
+        border: none;
+    }
+
+    .monaco-editor-expandable-section-expanded {
+        padding-bottom: 1px;
+        .pf-v6-c-card__expandable-content {
+            border: 1px solid var(--pf-t--global--border--color--default);
+            border-bottom: 1px solid 
var(--pf-t--global--border--color--default);
+        }
+    }
+
+    .monaco-editor-wrapper {
+        height: 100%;
+    }
+
+    .editor-with-preview {
+        position: relative;
+        overflow: visible !important;
+        height: 100%;
+        width: 100vw;
+        display: flex;
+        flex-direction: row;
+        justify-content: space-between;
+
+        .run-panel {
+            display: flex;
+            flex-direction: row;
+            justify-content: space-between;
+            align-items: center;
+            gap: 8px;
+            padding: 8px;
+            border-bottom: 1px solid 
var(--pf-t--global--border--color--default);
+
+            .pf-v6-c-form-control {
+                width: unset;
+            }
+            .pf-v6-c-content--p {
+                margin: 0;
+            }
+        }
+
+        .editor-panel {
+            height: 100%;
+            width: 100%;
+            display: flex;
+            flex-direction: column;
+            justify-content: flex-start;
+            /*gap: 6px;*/
+        }
+
+        .output-panel {
+            height: 100%;
+            width: 100%;
+            display: flex;
+            flex-direction: column;
+            justify-content: space-between;
+            gap: 6px;
+        }
+
+        .input-panel {
+            height: 100%;
+            width: 100%;
+            display: flex;
+            flex-direction: column;
+            justify-content: space-between;
+            gap: 6px;
+
+            .button-bar {
+                width: 100%;
+                display: flex;
+                flex-direction: row;
+                align-items: center;
+                justify-content: flex-start;
+                gap: 6px;
+                padding: 6px;
+
+                .pf-v6-c-button {
+                    padding: 4px;
+                    gap: 2px;
+                }
+            }
+        }
+
+        .preview-panel {
+            height: 100%;
+            width: 100%;
+
+
+            .wmde-markdown {
+                padding: 8px 0 8px 8px;
+                overflow: auto;
+                height: 100%;
+            }
+        }
+    }
+
+    .resize-handler {
+        background-color: var(--pf-t--global--border--color--default);
+        width: 3px;
+    }
+    .resize-handler-horizontal {
+        background-color: var(--pf-t--global--border--color--default);
+        height: 3px;
+    }
+
+    .wmde-markdown {
+        background-color: transparent;
+    }
+}
+.tryout-icon {
+    width: 1.4em;
+    height: 1.4em;
+    line-height: 1.4em;
+}
+
+/* AI CodeLens Styling */
+.monaco-editor .codelens-decoration a {
+    font-size: 12px !important;
+    padding-right: 10px;
+    display: inline-flex !important;
+    align-items: center;
+    gap: 4px;
+}
+
+/* Add this to your main stylesheet or editor CSS file */
+.camel-yaml-visible-link {
+    color: var(--pf-t--global--color--brand--default) !important; /* Standard 
link blue */
+    text-decoration: underline;
+    cursor: pointer !important;
+}
+
+/* Optional hover effect */
+.camel-yaml-visible-link:hover {
+    color: var(--pf-t--global--color--brand--hover) !important;
+}
+
+.comment-bracket-bold {
+    font-weight: bold !important;
+}
+.camel-variable-bold {
+    font-weight: bold !important;
+    color: var(--pf-t--color--orange--50) !important; /* Standard link blue */
+}
+
+.custom-clickable-token {
+    text-decoration: underline;
+    cursor: pointer !important;
+}
diff --git a/karavan-app/src/main/webui/src/ui/developer/DeveloperManager.tsx 
b/karavan-app/src/main/webui/src/ui/developer/DeveloperManager.tsx
new file mode 100644
index 00000000..a02dfcef
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/DeveloperManager.tsx
@@ -0,0 +1,23 @@
+import React, {lazy, Suspense} from 'react';
+import {Bullseye, Spinner} from "@patternfly/react-core";
+
+/**
+ * Lazy boundary in front of the file editor.
+ *
+ * All eight pages render this as `{showFilePanel && <DeveloperManager/>}`, so 
nothing here is
+ * needed until the user actually opens a file. The implementation statically 
pulls in every
+ * editor variant (Monaco + language services, the Camel designer, the OpenAPI 
designer and
+ * @apitomy/data-models) — roughly 9 MB — which used to be linked into all 
eight page chunks.
+ * Keeping the public component a thin shell defers that to the first file 
open and leaves the
+ * call sites unchanged.
+ */
+const DeveloperManagerImpl = lazy(() =>
+    import("./DeveloperManagerImpl").then(m => ({default: 
m.DeveloperManager})));
+
+export function DeveloperManager() {
+    return (
+        <Suspense fallback={<Bullseye><Spinner aria-label="Loading 
editor"/></Bullseye>}>
+            <DeveloperManagerImpl/>
+        </Suspense>
+    );
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/DeveloperManagerImpl.tsx 
b/karavan-app/src/main/webui/src/ui/developer/DeveloperManagerImpl.tsx
new file mode 100644
index 00000000..cd76eb54
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/DeveloperManagerImpl.tsx
@@ -0,0 +1,86 @@
+import React, {useEffect} from 'react';
+import {useFileStore} from "@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import './DeveloperManager.css'
+import {EditorErrorBoundaryWrapper} from 
"@developer/monaco/EditorErrorBoundaryWrapper";
+import {CamelDefinitionYaml} from "@core/api/CamelDefinitionYaml";
+import {DeveloperEditor} from "./DeveloperEditor";
+import {EditorType} from "@developer/monaco/EditorConfig";
+import DeveloperToolbar from "./DeveloperToolbar";
+import {useDeveloperToggleStore} from 
"@developer/toggle/useDeveloperToggleStore";
+import {DiffViewer} from "@developer/diff/DiffViewer";
+import {DesignerEditor} from "@developer/DesignerEditor";
+
+export function DeveloperManager() {
+
+    const [file] = useFileStore((s) => [s.file], shallow)
+    const developerView = useDeveloperToggleStore((s) => s.developerView)
+    const setDeveloperView = useDeveloperToggleStore((s) => s.setDeveloperView)
+
+    useEffect(() => {
+        if (isCamelYaml || isKameletYaml) {
+            setDeveloperView('designer');
+        }
+    }, [file?.name, file?.projectId])
+
+    function yamlIsCamel(): boolean {
+        if (file && file?.name.endsWith(".camel.yaml")) {
+            try {
+                const i = CamelDefinitionYaml.yamlToIntegration(file?.name, 
file?.code);
+            } catch (e: any) {
+                // EventBus.sendAlert(' ' + e?.name, '' + e?.message, 
'danger');
+                return false;
+            }
+            return true;
+        }
+        return false;
+    }
+
+    const isYaml =(file?.name.endsWith(".yaml") || 
file?.name.endsWith(".yml"));
+    const isCamelYaml = yamlIsCamel();
+    const isKameletYaml = file?.name.endsWith(".kamelet.yaml");
+    const isIntegration = isCamelYaml && (file?.code && 
CamelDefinitionYaml.yamlIsIntegration(file.code) || file?.code?.length === 0);
+    const showDesigner = developerView === 'designer' && ((isCamelYaml && 
isIntegration) || isKameletYaml);
+    const isMarkdown = file?.name.endsWith(".md");
+    const isGroovy = file?.name.endsWith(".groovy");
+    const isSql = file?.name.endsWith(".sql");
+    const isXml = file?.name.endsWith(".xml");
+    const isSvg = file?.name.endsWith(".svg");
+
+    function getDeveloperUI() {
+        if (developerView === 'diff') {
+            return <DiffViewer/>
+        } else if (showDesigner) {
+            return <DesignerEditor/>;
+        } else {
+            let editorType: EditorType;
+            if (isGroovy) {
+                editorType = 'groovy';
+            } else if (isSql) {
+                editorType = 'sql';
+            } else if (isYaml) {
+                editorType = 'yaml';
+            } else if (isMarkdown) {
+                editorType = 'markdown';
+            } else if (isXml) {
+                editorType = 'xml';
+            } else if (isSvg) {
+                editorType = 'svg';
+            } else {
+                editorType = 'json'; // default
+            }
+            return <DeveloperEditor editorType={editorType}/>
+        }
+    }
+
+    return (
+        <div id={"editor-manager"} className='editor-manager' 
key={`${file?.projectId}-${file?.name}`}>
+            <EditorErrorBoundaryWrapper onError={error => 
console.error(error)}>
+                <div id={"editor-manager-inner"} style={{display: 'flex', 
flexDirection: 'column', width: '99%', height: '100%'}}>
+                    <DeveloperToolbar/>
+                    {getDeveloperUI()}
+                </div>
+            </EditorErrorBoundaryWrapper>
+        </div>
+    )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/developer/DeveloperToolbar.css 
b/karavan-app/src/main/webui/src/ui/developer/DeveloperToolbar.css
new file mode 100644
index 00000000..f2d99531
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/DeveloperToolbar.css
@@ -0,0 +1,4 @@
+.developer-toolbar-validation-popover {
+    max-height: 70%;
+    overflow: auto;
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/developer/DeveloperToolbar.tsx 
b/karavan-app/src/main/webui/src/ui/developer/DeveloperToolbar.tsx
new file mode 100644
index 00000000..adafb437
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/DeveloperToolbar.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import {useFileStore} from "@stores/ProjectStore";
+import {ProjectTitle} from "@page-project/ProjectTitle";
+import {DeveloperToggle} from "@developer/toggle/DeveloperToggle";
+import {Button} from "@patternfly/react-core";
+import {UndoIcon} from "@patternfly/react-icons";
+import {useDeveloperToggleStore} from 
"@developer/toggle/useDeveloperToggleStore";
+import "./DeveloperToolbar.css"
+
+function DeveloperToolbar() {
+
+    const file = useFileStore((s) => s.file)
+    const fileCommited = useFileStore((s) => s.fileCommited)
+    const undoFile = useFileStore((s) => s.undoFile)
+    const developerView = useDeveloperToggleStore((s) => s.developerView)
+
+    const showUndoButton = developerView === 'diff' && (fileCommited?.code !== 
file?.code)
+
+    function getUndoButton() {
+        return <Button key="undo" variant="tertiary" isDanger 
icon={<UndoIcon/>} onClick={_ => undoFile()}>Undo</Button>;
+    }
+
+    return (
+        <div className="project-files-toolbar">
+            <ProjectTitle/>
+            {showUndoButton && getUndoButton()}
+            <DeveloperToggle/>
+        </div>
+    )
+}
+
+export default DeveloperToolbar
diff --git a/karavan-app/src/main/webui/src/ui/developer/MarkdownEditor.tsx 
b/karavan-app/src/main/webui/src/ui/developer/MarkdownEditor.tsx
new file mode 100644
index 00000000..f7ce6ba6
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/MarkdownEditor.tsx
@@ -0,0 +1,69 @@
+import React, {JSX, useEffect, useRef, useState} from 'react';
+import {useFileStore} from "@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {useTheme} from "@compass/theme/ThemeContext";
+import {MonacoEditorWrapper} from "./monaco/MonacoEditorWrapper";
+import MarkdownPreview, {MarkdownPreviewRef} from 
"@uiw/react-markdown-preview";
+import {usePanelRef} from "react-resizable-panels";
+import {useDebounceValue} from "usehooks-ts";
+import {ErrorBoundaryWrapper} from '@shared/ui/ErrorBoundaryWrapper';
+import {defaultEditorOptions} from "@developer/monaco/EditorConfig";
+import {useDeveloperToggleStore} from 
"@developer/toggle/useDeveloperToggleStore";
+
+export interface MarkdownEditorProps {
+    projectId: string;
+    filename: string;
+    initialCode?: string;
+    onChange?: (value: (string | undefined)) => void;
+}
+
+export function MarkdownEditor(props: MarkdownEditorProps): JSX.Element {
+
+    const {projectId, filename, initialCode, onChange} = props;
+    const {isDark} = useTheme();
+    const [file] = useFileStore((s) => [s.file], shallow);
+    const developerView = useDeveloperToggleStore((s) => s.developerView)
+    const [scrollPercent, setScrollPercent] = useState<number>(0);
+    const [debouncedScroll] = useDebounceValue(scrollPercent, 100);
+    const previewRef = useRef<MarkdownPreviewRef>(null);
+    const ref = usePanelRef();
+
+    useEffect(() => {
+        if (previewRef.current) {
+            const preview = previewRef.current.mdp.current;
+            if (preview) {
+                const previewScrollHeight = preview.scrollHeight - 
preview.clientHeight;
+                preview.scrollTop = previewScrollHeight * scrollPercent;
+            }
+        }
+    }, [debouncedScroll]);
+
+    return developerView === 'preview'
+        ? <ErrorBoundaryWrapper onError={error => console.error(error)}>
+            <div style={{overflowY: "auto"}}>
+                <MarkdownPreview
+                    components={{
+                        a: ({ node, ...props }) => {
+                            if (props.className?.includes('anchor')) {
+                                return null;
+                            }
+                            return <a {...props}>{props.children}</a>;
+                        }
+                    }}
+                    style={{padding: 8}}
+                    key={"MarkdownEditorPreview"}
+                    source={file?.code}
+                    wrapperElement={{'data-color-mode': isDark ? 'dark' : 
'light'}}
+                    ref={previewRef}
+                />
+            </div>
+        </ErrorBoundaryWrapper>
+        : <MonacoEditorWrapper
+            key={`${projectId}/${filename}`}
+            language="markdown"
+            editorOptions={defaultEditorOptions}
+            onScroll={sp => setScrollPercent(sp)}
+            onChange={onChange}
+            initialCode={initialCode}
+        />
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/developer/diff/DiffViewer.css 
b/karavan-app/src/main/webui/src/ui/developer/diff/DiffViewer.css
new file mode 100644
index 00000000..7ef053a4
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/diff/DiffViewer.css
@@ -0,0 +1,4 @@
+.diff-viewer {
+    height: 100%;
+    width: 100%;
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/developer/diff/DiffViewer.tsx 
b/karavan-app/src/main/webui/src/ui/developer/diff/DiffViewer.tsx
new file mode 100644
index 00000000..090fae38
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/diff/DiffViewer.tsx
@@ -0,0 +1,59 @@
+import React, {JSX, useEffect, useMemo, useState} from 'react';
+import './DiffViewer.css'
+import {EditorErrorBoundaryWrapper} from 
"@developer/monaco/EditorErrorBoundaryWrapper";
+import {useFileStore} from "@stores/ProjectStore";
+import {MonacoDiffEditor} from "@shared/MonacoEditor";
+import {useTheme} from "@compass/theme/ThemeContext";
+
+const languages = new Map<string, string>([
+    ['sh', 'shell'],
+    ['md', 'markdown'],
+    ['properties', 'ini']
+])
+
+export function DiffViewer(): JSX.Element {
+
+    const { isDark } = useTheme();
+    const file = useFileStore((s) => s.file)
+    const fetchCommitedFile = useFileStore((s) => s.fetchCommitedFile)
+    const fileCommited = useFileStore((s) => s.fileCommited)
+    const [code, setCode] = useState<string>(file?.code ?? "");
+
+    useEffect(() => {
+        if (file) {
+            fetchCommitedFile(file).then(_ => setCode(file.code))
+        }
+    }, [file?.projectId, file?.name, file?.lastUpdate]);
+
+    const options = useMemo(
+        () => ({
+            readOnly: true,
+            renderSideBySide: true, // set false for inline diff
+            minimap: { enabled: false },
+            wordWrap: "on" as const,
+            scrollBeyondLastLine: false,
+            automaticLayout: true,
+            // makes it feel more "diff-like"
+            renderIndicators: true,
+        }),
+        []
+    );
+
+    const extension = file?.name.split('.').pop();
+    const language = extension && languages.has(extension) ? 
languages.get(extension) : extension;
+
+    return (
+        <div className='diff-viewer'>
+            <EditorErrorBoundaryWrapper onError={error => 
console.error(error)}>
+                <MonacoDiffEditor
+                    height="100%"
+                    language={language}
+                    original={fileCommited?.code || ''}
+                    modified={code || ''}
+                    theme={isDark ? "vs-dark" : "vs-light"}
+                    options={options}
+                />
+            </EditorErrorBoundaryWrapper>
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/groovy/GroovyEditor.tsx 
b/karavan-app/src/main/webui/src/ui/developer/groovy/GroovyEditor.tsx
new file mode 100644
index 00000000..41393b58
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/groovy/GroovyEditor.tsx
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, {JSX, useCallback, useEffect, useMemo, useRef, useState} from 
'react';
+import {MonacoEditorWrapper} from "../monaco/MonacoEditorWrapper";
+import {useProjectStore} from "@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {defaultEditorOptions} from "@developer/monaco/EditorConfig";
+import * as monaco from 'monaco-editor';
+import {ProcessorName} from "@core/model/ExchangeDefinitions";
+import {Group} from "react-resizable-panels";
+
+export interface GroovyEditorProps {
+    processorName: ProcessorName;
+    paramName?: string;
+    filename: string;
+    stepId?: string;
+    initialCode?: string;
+    onChange?: (value: string | undefined) => void;
+    onLinkOpen?: (uri: monaco.Uri) => void;
+}
+
+export function GroovyEditor(props: GroovyEditorProps): JSX.Element {
+    const {filename, initialCode, onChange, onLinkOpen} = props;
+    const [project] = useProjectStore((s) => [s.project], shallow);
+
+    function onCodeChange(code: string | undefined) {
+        onChange?.(code);
+    }
+
+
+    return (
+        <Group orientation="horizontal" className='editor-with-preview' 
key={'groovyEditorKey'}>
+                <MonacoEditorWrapper
+                    key={`${project.projectId}/${filename}`}
+                    language="groovy"
+                    editorOptions={defaultEditorOptions}
+                    initialCode={initialCode}
+                    onChange={onCodeChange}
+                    onLinkOpen={onLinkOpen}
+                />
+            {/*</Panel>*/}
+        </Group>
+    )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/developer/monaco/EditorConfig.ts 
b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorConfig.ts
new file mode 100644
index 00000000..efd798ef
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorConfig.ts
@@ -0,0 +1,26 @@
+import type * as monaco from "monaco-editor";
+
+export type EditorType = 'groovy' | 'sql' | 'json' | 'yaml' | 'markdown' | 
'xml' | 'svg'
+
+export const defaultEditorOptions: 
monaco.editor.IStandaloneEditorConstructionOptions = {
+    minimap: {enabled: false},
+    scrollBeyondLastLine: false,
+    scrollbar: {
+        useShadows: false,
+    },
+    selectOnLineNumbers: true,
+    automaticLayout: true,
+    lineNumbers: "on",
+    folding: true,
+    lineNumbersMinChars: 3,
+    showUnused: false,
+    fontSize: 12,
+    fixedOverflowWidgets: false,
+    suggest: {
+        showKeywords: true,
+        showStatusBar: true,
+        showIcons: true,
+        preview: true,
+        showSnippets: true,
+    },
+};
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/monaco/EditorErrorBoundaryWrapper.tsx
 
b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorErrorBoundaryWrapper.tsx
new file mode 100644
index 00000000..9d30451c
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorErrorBoundaryWrapper.tsx
@@ -0,0 +1,66 @@
+import React, {ReactNode} from "react";
+import {MonacoEditorWrapper} from "./MonacoEditorWrapper";
+import {Alert} from "@patternfly/react-core";
+import type * as monaco from "monaco-editor";
+
+export interface EditorErrorBoundaryState {
+    hasError: boolean;
+    error: Error | null;
+}
+
+const editorOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
+    minimap: {enabled: false},
+    scrollBeyondLastLine: false,
+    scrollbar: {
+        useShadows: false,
+    },
+    selectOnLineNumbers: true,
+    automaticLayout: true,
+    lineNumbers: "on",
+    folding: false,
+    lineNumbersMinChars: 3,
+    showUnused: false,
+    fontSize: 12,
+    fixedOverflowWidgets: false,
+    suggest: {
+        showKeywords: false,
+        showStatusBar: true,
+        showIcons: true,
+        preview: true,
+        showSnippets: true,
+    },
+};
+
+interface ErrorBoundaryProps {
+    children: ReactNode;
+    onError: (error: Error) => void;
+}
+
+export class EditorErrorBoundaryWrapper extends 
React.Component<ErrorBoundaryProps, EditorErrorBoundaryState> {
+    constructor(props: ErrorBoundaryProps) {
+        super(props);
+        this.state = { hasError: false, error: null };
+    }
+
+    static getDerivedStateFromError(error: Error): EditorErrorBoundaryState {
+        return { hasError: true, error };
+    }
+
+    componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+        // Handle error (logging, etc.)
+        console.error("Error caught in ErrorBoundary:", error, errorInfo);
+        this.props.onError(error);
+    }
+
+    render() {
+        if (this.state.hasError) {
+            return (
+                <div className="editor-error">
+                    <Alert title="Error" variant='danger' style={{margin: 
8}}>Something went wrong: {this.state.error?.message}</Alert>
+                    <MonacoEditorWrapper language="text" 
editorOptions={editorOptions} />
+                </div>
+            )
+        }
+        return this.props.children;
+    }
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/monaco/EditorOverlayWidget.css 
b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorOverlayWidget.css
new file mode 100644
index 00000000..2eaaa430
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorOverlayWidget.css
@@ -0,0 +1,12 @@
+.my-overlay-widget {
+    position: absolute;
+    margin-top: 3px;
+    margin-right: -16px;
+    z-index: 3000;
+    padding: 2px 4px;
+    border-radius: 6px;
+    background: var(--pf-t--global--background--color--secondary--default);
+    border: 1px solid 
var(--pf-t--global--border--color--nonstatus--gray--default);
+    /*font-size: 12px;*/
+    /*pointer-events: auto; !* allow clicks *!*/
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/monaco/EditorOverlayWidget.tsx 
b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorOverlayWidget.tsx
new file mode 100644
index 00000000..210c22eb
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorOverlayWidget.tsx
@@ -0,0 +1,33 @@
+import ReactDOM from 'react-dom/client';
+import "./EditorOverlayWidget.css"
+import {Content} from "@patternfly/react-core";
+import {editor} from "monaco-editor";
+import OverlayWidgetPositionPreference = 
editor.OverlayWidgetPositionPreference;
+
+export function createEditorOverlayWidget(title?: string, element?: 
React.JSX.Element): editor.IOverlayWidget {
+    const domNode = document.createElement('div');
+    domNode.className = 'my-overlay-widget';
+
+    const root = ReactDOM.createRoot(domNode);
+    root.render(
+        <div style={{display: 'flex', alignItems: "center", gap: "6px"}}>
+            <Content component='p'>{title}</Content>
+            {element}
+        </div>
+    );
+
+    return {
+        getId() {
+            return 'my.react.overlay.widget';
+        },
+        getDomNode() {
+            return domNode;
+        },
+        getPosition() {
+            return {
+                preference:
+                OverlayWidgetPositionPreference.TOP_RIGHT_CORNER,
+            };
+        },
+    };
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/monaco/EditorToolbar.tsx 
b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorToolbar.tsx
new file mode 100644
index 00000000..b3c6ed79
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/monaco/EditorToolbar.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import '@designer/karavan.css';
+import {useProjectStore} from "@stores/ProjectStore";
+import {BUILD_IN_PROJECTS} from "@models/ProjectModels";
+import {DevModeToolbar} from "@page-project/toolbar/DevModeToolbar";
+import {ProjectContainersContextProvider} from 
"@page-project/ProjectContainersContextProvider";
+import {useAppConfig} from "@compass/useConfig";
+
+export function EditorToolbar() {
+
+    const project = useProjectStore((s) => s.project)
+    const tabIndex = useProjectStore((s) => s.tabIndex)
+    const {isDev} = useAppConfig();
+
+    const isBuildInProject = BUILD_IN_PROJECTS.includes(project.projectId);
+
+    function isRunnable(): boolean {
+        return isDev && !isBuildInProject && !['build', 
'container'].includes(tabIndex.toString());
+    }
+
+    return (
+        <div id="toolbar-group-types">
+            <div style={{display: 'flex', justifyContent: 'space-between', 
alignItems: 'center', gap: '8px'}}>
+                {isRunnable() &&
+                    <ProjectContainersContextProvider>
+                        <DevModeToolbar/>
+                    </ProjectContainersContextProvider>
+                }
+            </div>
+        </div>
+    )
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapper.tsx 
b/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapper.tsx
new file mode 100644
index 00000000..e436032e
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapper.tsx
@@ -0,0 +1,23 @@
+import React, {lazy, Suspense} from "react";
+import {Bullseye, Spinner} from "@patternfly/react-core";
+// Type-only, so importing the props does not pull the implementation (and 
Monaco) in eagerly.
+import type {MonacoEditorProps} from "./MonacoEditorWrapperImpl";
+
+export type {MonacoEditorProps};
+
+/**
+ * Lazy boundary in front of the real editor.
+ *
+ * monaco-editor plus its language services is ~4 MB. Every page that embeds 
an editor used to
+ * pay for it as soon as the page chunk loaded, even when the editor sat 
behind a tab or a panel
+ * the user never opened. Keeping the public component a thin shell means 
Monaco is fetched the
+ * first time an editor actually mounts, and call sites need no changes.
+ */
+const MonacoEditorWrapperImpl = lazy(() =>
+    import("./MonacoEditorWrapperImpl").then(m => ({default: 
m.MonacoEditorWrapper})));
+
+export const MonacoEditorWrapper: React.FC<MonacoEditorProps> = (props) => (
+    <Suspense fallback={<Bullseye><Spinner aria-label="Loading 
editor"/></Bullseye>}>
+        <MonacoEditorWrapperImpl {...props}/>
+    </Suspense>
+);
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapperImpl.tsx
 
b/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapperImpl.tsx
new file mode 100644
index 00000000..88a915b6
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapperImpl.tsx
@@ -0,0 +1,221 @@
+import React, {useEffect, useRef} from "react";
+import type * as monaco from "monaco-editor";
+import {IDisposable} from "monaco-editor";
+import {MonacoEditor, type MonacoOnMount} from "@shared/MonacoEditor";
+import {useTheme} from "@compass/theme/ThemeContext";
+import {createEditorOverlayWidget} from 
"@developer/monaco/EditorOverlayWidget";
+import {useAppConfig} from "@compass/useConfig";
+
+export interface MonacoEditorProps {
+    language: string;
+    onChange?: (value: string | undefined) => void;
+    onScroll?: (scrollPercent: number) => void;
+    onEditorDidMount?: (editor: monaco.editor.IStandaloneCodeEditor,
+                        monacoInstance: typeof monaco) => void;
+    editorOptions?: monaco.editor.IStandaloneEditorConstructionOptions;
+    height?: string;
+    width?: string;
+    initialCode?: string;
+    contextKeys?: Map<string, boolean>
+    contextMenuActions?: monaco.editor.IActionDescriptor[]
+    markers?: monaco.editor.IMarkerData[]
+    decorations?: monaco.editor.IModelDeltaDecoration[]
+    codeActionProvider?: monaco.languages.CodeActionProvider;
+    readOnly?: boolean;
+    onLinkOpen?: (uri: monaco.Uri) => void;
+    title?: string;
+    element?: React.JSX.Element
+    onBeforeMount?: (monacoInstance: typeof monaco) => void;
+    completionProvider?: monaco.languages.CompletionItemProvider;
+    inlineCompletionsProvider?: 
monaco.languages.InlineCompletionsProvider<monaco.languages.InlineCompletions>;
+    codeLensProvider?: monaco.languages.CodeLensProvider;
+}
+
+export const MonacoEditorWrapper: React.FC<MonacoEditorProps> = ({
+                                                                     language,
+                                                                     onChange,
+                                                                     
editorOptions,
+                                                                     height = 
"100%",
+                                                                     width = 
"100%",
+                                                                     
completionProvider,
+                                                                     
initialCode,
+                                                                     onScroll,
+                                                                     
contextKeys = new Map<string, boolean>(),
+                                                                     
onEditorDidMount,
+                                                                     
contextMenuActions = [],
+                                                                     markers = 
[],
+                                                                     
decorations = [],
+                                                                     
codeActionProvider,
+                                                                     readOnly 
= false,
+                                                                     
onLinkOpen,
+                                                                     title,
+                                                                     element,
+                                                                     
onBeforeMount,
+                                                                     
inlineCompletionsProvider,
+                                                                     
codeLensProvider,
+                                                                 }) => {
+    const {isDark} = useTheme();
+    const {isDev} = useAppConfig();
+    const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
+    const monacoRef = useRef<typeof monaco | null>(null);
+    const providerRefCompletionItem = useRef<monaco.IDisposable | null>(null);
+    const decorationsCollectionRef = 
useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
+    const providerRefCodeAction = useRef<monaco.IDisposable | null>(null);
+    const providerRefLinkProvider = useRef<monaco.IDisposable | null>(null);
+    const providerRefLinkOpener = useRef<monaco.IDisposable | null>(null);
+    const providerRefInlineCompletion = useRef<monaco.IDisposable | 
null>(null);
+    const providerRefCodeLens = useRef<monaco.IDisposable | null>(null);
+
+    useEffect(() => {
+        return () => disposeRefs();
+    }, [])
+
+    useEffect(() => {
+        if (editorRef.current && monacoRef.current) {
+            const model = editorRef.current.getModel();
+            if (model) {
+                monacoRef.current.editor.setModelMarkers(model, 
'validation-owner', markers);
+            }
+        }
+    }, [markers]);
+
+    useEffect(() => {
+        decorationsCollectionRef.current?.set(decorations);
+    }, [decorations]);
+
+    useEffect(() => {
+        if (monacoRef.current) {
+            if (inlineCompletionsProvider) {
+                providerRefInlineCompletion.current?.dispose();
+                providerRefInlineCompletion.current = 
monacoRef.current.languages.registerInlineCompletionsProvider(language, 
inlineCompletionsProvider);
+            }
+            if (codeLensProvider) {
+                providerRefCodeLens.current?.dispose();
+                providerRefCodeLens.current = 
monacoRef.current.languages.registerCodeLensProvider(language, 
codeLensProvider);
+            }
+        }
+    }, [inlineCompletionsProvider, codeLensProvider, language]);
+
+    function disposeRefs() {
+        providerRefCompletionItem.current?.dispose();
+        providerRefCodeAction.current?.dispose();
+        providerRefLinkProvider.current?.dispose();
+        providerRefLinkOpener.current?.dispose();
+        providerRefInlineCompletion.current?.dispose();
+        providerRefCodeLens.current?.dispose();
+    }
+
+    const handleBeforeMount = (monacoInstance: typeof monaco) => {
+        disposeRefs();
+
+        onBeforeMount?.(monacoInstance);
+
+        if (onLinkOpen) {
+            providerRefLinkProvider.current = 
monacoInstance.languages.registerLinkProvider(language, {
+                provideLinks(model) {
+                    const text = model.getValue();
+                    const links: monaco.languages.ILink[] = [];
+
+                    const refRegex = 
/\$ref"\s*:\s*"((?:\.{1,2}\/)[^"#]+\.json(?:#[^"]*)?)"/g;
+                    for (const match of text.matchAll(refRegex)) {
+                        const startIdx = (match.index ?? 0) + 
match[0].indexOf(match[1]);
+                        const endIdx = startIdx + match[1].length;
+                        const start = model.getPositionAt(startIdx);
+                        const end = model.getPositionAt(endIdx);
+
+                        links.push({
+                            range: new monacoInstance.Range(
+                                start.lineNumber, start.column,
+                                end.lineNumber, end.column
+                            ),
+                            url: monacoInstance.Uri.parse(match[1]),
+                            tooltip: `Open schema ${match[1]}`,
+                        });
+                    }
+
+                    return {links};
+                },
+            });
+
+            providerRefLinkOpener.current = 
monacoInstance.editor.registerLinkOpener({
+                open: (resource) => {
+                    if (resource.scheme === 'file' || resource.scheme === 
'camel-yaml' || resource.scheme === 'project-file') {
+                        onLinkOpen?.(resource);
+                        return true;
+                    }
+                    return false;
+                },
+            });
+        }
+
+        if (completionProvider) {
+            const scopedCompletionProvider: 
monaco.languages.CompletionItemProvider = {
+                triggerCharacters: completionProvider.triggerCharacters,
+                provideCompletionItems: (model, position, context, 
tokenSource) => {
+                    if (model !== editorRef.current?.getModel()) {
+                        return {suggestions: []};
+                    }
+                    return completionProvider.provideCompletionItems(model, 
position, context, tokenSource);
+                },
+                resolveCompletionItem: completionProvider.resolveCompletionItem
+                    ? (item, tokenSource) => 
completionProvider.resolveCompletionItem!(item, tokenSource)
+                    : undefined,
+            };
+            providerRefCompletionItem.current = 
monacoInstance.languages.registerCompletionItemProvider(language, 
scopedCompletionProvider);
+        }
+        if (codeActionProvider) {
+            providerRefCodeAction.current = 
monacoInstance.languages.registerCodeActionProvider(language, 
codeActionProvider);
+        }
+        if (inlineCompletionsProvider) {
+            providerRefInlineCompletion.current = 
monacoInstance.languages.registerInlineCompletionsProvider(language, 
inlineCompletionsProvider);
+        }
+
+        if (codeLensProvider) {
+            providerRefCodeLens.current = 
monacoInstance.languages.registerCodeLensProvider(language, codeLensProvider);
+        }
+    };
+
+    const handleOnMount: MonacoOnMount = (editor: 
monaco.editor.IStandaloneCodeEditor,
+                                          monacoInstance: typeof monaco) => {
+        editorRef.current = editor;
+        monacoRef.current = monacoInstance;
+        decorationsCollectionRef.current = 
editor.createDecorationsCollection();
+        onEditorDidMount?.(editor, monacoInstance);
+
+        editor.onDidScrollChange(() => {
+            const current = editorRef.current;
+            if (!current) return;
+            const scrollTop = current.getScrollTop();
+            const scrollHeight = current.getScrollHeight();
+            const clientHeight = current.getDomNode()?.clientHeight || 1;
+            const scrollPercent = scrollTop / (scrollHeight - clientHeight);
+            onScroll?.(scrollPercent);
+        });
+
+        contextKeys.entries().forEach(contextKey => 
editor.createContextKey(contextKey[0], contextKey[1]));
+        const disposables: IDisposable[] = contextMenuActions.map(action => 
editor.addAction(action));
+
+        const widget = createEditorOverlayWidget(title, element);
+        if (title && element) editor.addOverlayWidget(widget)
+
+        return () => {
+            disposables.forEach(disposable => disposable?.dispose?.());
+            decorationsCollectionRef.current?.clear();
+        };
+    };
+
+    return (
+        <MonacoEditor
+            className={'monaco-editor-wrapper'}
+            language={language}
+            value={initialCode}
+            onChange={(value) => onChange?.(value)}
+            options={{...editorOptions, readOnly: readOnly || !isDev}}
+            height={height}
+            width={width}
+            theme={isDark ? 'vs-dark' : 'light'}
+            beforeMount={handleBeforeMount}
+            onMount={handleOnMount}
+        />
+    );
+};
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapperStateless.tsx
 
b/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapperStateless.tsx
new file mode 100644
index 00000000..20e2214f
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/developer/monaco/MonacoEditorWrapperStateless.tsx
@@ -0,0 +1,99 @@
+import React, {useEffect, useRef, useState} from "react";
+import type * as monaco from "monaco-editor";
+import {MonacoEditor, type MonacoOnMount} from "@shared/MonacoEditor";
+import {useTheme} from "@compass/theme/ThemeContext";
+import {useDebounceValue} from "usehooks-ts";
+import {defaultEditorOptions} from "@developer/monaco/EditorConfig";
+import {useAppConfig} from "@compass/useConfig";
+
+const MIN_HEIGHT = 100; // px
+const MAX_HEIGHT = 400; // px
+
+export interface MonacoEditorProps {
+    language: string;
+    onChange?: (value: string | undefined) => void;
+    onDidBlurEditorText?: () => void;
+    editorOptions?: monaco.editor.IStandaloneEditorConstructionOptions;
+    height?: string;
+    width?: string;
+    initialCode: string;
+    completionProvider?: monaco.languages.CompletionItemProvider;
+}
+
+export const MonacoEditorWrapperStateless: React.FC<MonacoEditorProps> = ({
+                                                                              
language,
+                                                                              
onChange,
+                                                                              
editorOptions,
+                                                                              
height,
+                                                                              
width = "100%",
+                                                                              
completionProvider, initialCode,onDidBlurEditorText
+                                                                          }) 
=> {
+    const {isDark} = useTheme();
+    const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
+    const providerRefCompletionItem = useRef<monaco.IDisposable | null>(null);
+    const [autoHeight, setAutoHeight] = useState(MIN_HEIGHT);
+    const [code, setCode] = useState<string | undefined>(initialCode);
+    const [debouncedCode] = useDebounceValue(code, 300);
+    const {isDev} = useAppConfig();
+
+    useEffect(() => {
+        return () => disposeRefs();
+    }, [])
+
+    useEffect(() => {
+        onChange?.(debouncedCode);
+    }, [debouncedCode]);
+
+    useEffect(() => {
+        return () => disposeRefs();
+    }, [])
+
+    function disposeRefs() {
+        providerRefCompletionItem.current?.dispose();
+    }
+
+    const handleEditorWillMount = (monacoInstance: typeof monaco) => {
+        if (completionProvider) {
+            disposeRefs();
+            monacoInstance.languages.registerCompletionItemProvider(language, 
completionProvider);
+        }
+    };
+
+    const handleEditorDidMount: MonacoOnMount = (editor) => {
+        editorRef.current = editor;
+        resizeEditor();
+
+        editor.onDidContentSizeChange(() => {
+            resizeEditor();
+        });
+
+        editor.onDidBlurEditorText(() => {
+            onDidBlurEditorText?.()
+        });
+    };
+
+    const resizeEditor = () => {
+        if (!editorRef.current) return;
+
+        const contentHeight = editorRef.current.getContentHeight();
+        const newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, 
contentHeight));
+
+        setAutoHeight(newHeight);
+        editorRef.current.layout({width: 
editorRef.current.getLayoutInfo().width, height: newHeight});
+    };
+
+    const options= editorOptions || defaultEditorOptions;
+    return (
+        <MonacoEditor className={'monaco-editor-wrapper-stateless'}
+                      language={language}
+                      value={code}
+                      onChange={(value) => setCode(value)}
+                      options={{...editorOptions, readOnly: !isDev}}
+                      height={height ?? autoHeight}
+                      width={width}
+                      theme={isDark ? 'vs-dark' : 'light'}
+                      beforeMount={handleEditorWillMount}
+                      onMount={handleEditorDidMount}
+        />
+    );
+};
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/toggle/DeveloperToggle.tsx 
b/karavan-app/src/main/webui/src/ui/developer/toggle/DeveloperToggle.tsx
new file mode 100644
index 00000000..43d18042
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/developer/toggle/DeveloperToggle.tsx
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {ToggleGroup, ToggleGroupItem,} from '@patternfly/react-core';
+import {useFileStore} from "@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {getProjectFileTypeByName} from "@models/ProjectModels";
+import {CodeIcon} from "@patternfly/react-icons";
+import {DeploymentPattern, FileDiff, Thumbnail_1} from "@carbon/icons-react";
+import {useDeveloperToggleStore} from 
"@developer/toggle/useDeveloperToggleStore";
+
+export function DeveloperToggle() {
+
+    const [file] = useFileStore((s) => [s.file], shallow);
+    const [setDesignerSwitch, designerSwitch] = useDeveloperToggleStore((s) => 
[s.setDeveloperView, s.developerView], shallow)
+
+    const fileType = getProjectFileTypeByName(file?.name || "");
+    const showDesigner = fileType.filter(ft => ['INTEGRATION', 'KAMELET', 
'OPENAPI', 'ASYNCAPI'].includes(ft.name)).length > 0;
+    const showPreview = fileType.filter(ft => ['MARKDOWN', 
'SVG'].includes(ft.name)).length > 0;
+
+    return (
+        <ToggleGroup aria-label="DeveloperToggle" 
className={"designer-toggle"}>
+            {showDesigner &&
+                <ToggleGroupItem
+                    icon={<DeploymentPattern className='carbon'/>}
+                    aria-label="designer"
+                    text='Designer'
+                    buttonId="designer"
+                    isSelected={designerSwitch === 'designer'}
+                    onChange={(_, __) => setDesignerSwitch('designer')}
+                />
+            }
+            <ToggleGroupItem
+                icon={<CodeIcon/>}
+                text='Code'
+                aria-label="code"
+                buttonId="code"
+                isSelected={designerSwitch === 'code'}
+                onChange={(_, __) => setDesignerSwitch('code')}
+            />
+            {showPreview &&
+                <ToggleGroupItem
+                    icon={<Thumbnail_1 className='carbon'/>}
+                    text='Preview'
+                    aria-label="preview"
+                    buttonId="preview"
+                    isSelected={designerSwitch === 'preview'}
+                    onChange={(_, __) => setDesignerSwitch("preview")}
+                />
+            }
+            <ToggleGroupItem
+                icon={<FileDiff className='carbon'/>}
+                text='Diff'
+                aria-label="diff"
+                buttonId="diff"
+                isSelected={designerSwitch === 'diff'}
+                onChange={(_, __) => setDesignerSwitch("diff")}
+            />
+        </ToggleGroup>
+    )
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/developer/toggle/useDeveloperToggleStore.ts 
b/karavan-app/src/main/webui/src/ui/developer/toggle/useDeveloperToggleStore.ts
new file mode 100644
index 00000000..066909a7
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/developer/toggle/useDeveloperToggleStore.ts
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {createWithEqualityFn} from "zustand/traditional";
+import {shallow} from "zustand/shallow";
+
+export const DeveloperToggleOptions = ["designer", "code", "diff", "preview"] 
as const;
+export type DeveloperToggleOption = typeof DeveloperToggleOptions[number];
+
+
+interface DeveloperToggleState {
+    developerView: DeveloperToggleOption,
+
+    // Actions
+    setDeveloperView: (developerView: DeveloperToggleOption) => void;
+}
+
+export const useDeveloperToggleStore = 
createWithEqualityFn<DeveloperToggleState>((set, get) => ({
+    developerView: DeveloperToggleOptions[0],
+
+    setDeveloperView: (developerView: DeveloperToggleOption) => {
+        set({developerView: developerView});
+    }
+}), shallow);

Reply via email to