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);
