275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import React, { memo, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
|
import Editor, { loader } from '@monaco-editor/react'
|
|
import FlowContext from '../../lib/flowContext'
|
|
import {
|
|
BaseNode,
|
|
BaseNodeContent,
|
|
BaseNodeFooter,
|
|
BaseNodeHeader,
|
|
BaseNodeHeaderTitle,
|
|
} from './BaseNode'
|
|
import { GitBranchPlus, Pencil } from 'lucide-react'
|
|
import {
|
|
Menubar,
|
|
MenubarContent,
|
|
MenubarItem,
|
|
MenubarLabel,
|
|
MenubarMenu,
|
|
MenubarSeparator,
|
|
MenubarSub,
|
|
MenubarSubContent,
|
|
MenubarSubTrigger,
|
|
MenubarTrigger,
|
|
} from '../ui/menubar'
|
|
import { InputHandle, OutputHandle } from './NodeHandles'
|
|
|
|
// Ensure Monaco can load its web workers under Vite by pointing to a CDN
|
|
loader.config({ paths: { vs: 'https://unpkg.com/monaco-editor@latest/min/vs' } })
|
|
|
|
type Props = {
|
|
id: string
|
|
data: any
|
|
}
|
|
|
|
export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
|
|
const [value, setValue] = useState<string>(data?.yaml ?? '# Enter YAML here\n')
|
|
|
|
const ctx = useContext(FlowContext)
|
|
const setNodes = ctx?.setNodes
|
|
const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? ''
|
|
|
|
const editorRef = useRef<any>(null)
|
|
const monacoRef = useRef<any>(null)
|
|
|
|
const incomingEdges = ctx?.edges?.filter((e: any) => e.target === id) ?? []
|
|
const incomingIds = incomingEdges.map((e: any) => e.source).sort()
|
|
const connectedConfigNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config')
|
|
const connectedVariableNodes = (ctx?.nodes ?? []).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable')
|
|
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0
|
|
|
|
useEffect(() => {
|
|
if (setNodes) {
|
|
setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, yaml: value } } : n)))
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
const onChange = useCallback(
|
|
(val?: string) => {
|
|
const v = val ?? ''
|
|
setValue(v)
|
|
// eslint-disable-next-line no-console
|
|
console.debug('ConfigNode:onChange', id, v.substring(0, 60))
|
|
if (setNodes) {
|
|
setNodes((nds: any[]) => nds.map((n) => (n.id === id ? { ...n, data: { ...n.data, yaml: v } } : n)))
|
|
}
|
|
},
|
|
[id, setNodes]
|
|
)
|
|
|
|
const handleEditorMount = useCallback((editor: any, monaco: any) => {
|
|
editorRef.current = editor
|
|
monacoRef.current = monaco
|
|
|
|
// Prevent React Flow and other global handlers from interfering with typing
|
|
try {
|
|
editor.onKeyDown((e: any) => {
|
|
if (e?.browserEvent) {
|
|
e.browserEvent.stopPropagation()
|
|
}
|
|
})
|
|
editor.onMouseDown((e: any) => {
|
|
if (e?.event) {
|
|
e.event.stopPropagation()
|
|
}
|
|
})
|
|
} catch {
|
|
// ignore if Monaco internals change
|
|
}
|
|
}, [])
|
|
|
|
const insertIncludeFromNode = useCallback(
|
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
|
const ref = `${sourceNode.id}.yaml`
|
|
const includeText = `!include ${ref}\n`
|
|
const editor = editorRef.current
|
|
const monaco = monacoRef.current
|
|
|
|
try {
|
|
if (editor && monaco) {
|
|
const model = editor.getModel()
|
|
const lineCount = model.getLineCount()
|
|
const selection = editor.getSelection()
|
|
|
|
let range
|
|
if (mode === 'prepend') {
|
|
range = new monaco.Range(1, 1, 1, 1)
|
|
} else if (mode === 'append') {
|
|
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
|
|
} else if (selection) {
|
|
range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.startLineNumber, selection.startColumn)
|
|
} else {
|
|
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
|
|
}
|
|
|
|
editor.executeEdits('insert-include', [{ range, text: includeText, forceMoveMarkers: true }])
|
|
const newVal = editor.getModel().getValue()
|
|
onChange(newVal)
|
|
return
|
|
}
|
|
|
|
if (mode === 'prepend') {
|
|
onChange(includeText + value)
|
|
} else {
|
|
onChange(value + '\n' + includeText)
|
|
}
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Insert include failed', err)
|
|
}
|
|
},
|
|
[onChange, value]
|
|
)
|
|
|
|
const insertVariableReference = useCallback(
|
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
|
const insertText = `\${${sourceNode.id}}`
|
|
const editor = editorRef.current
|
|
const monaco = monacoRef.current
|
|
|
|
try {
|
|
if (editor && monaco) {
|
|
const model = editor.getModel()
|
|
const lineCount = model.getLineCount()
|
|
const selection = editor.getSelection()
|
|
|
|
let range
|
|
if (mode === 'prepend') {
|
|
range = new monaco.Range(1, 1, 1, 1)
|
|
} else if (mode === 'append') {
|
|
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
|
|
} else if (selection) {
|
|
range = new monaco.Range(selection.startLineNumber, selection.startColumn, selection.startLineNumber, selection.startColumn)
|
|
} else {
|
|
range = new monaco.Range(lineCount + 1, 1, lineCount + 1, 1)
|
|
}
|
|
|
|
editor.executeEdits('insert-var', [{ range, text: insertText, forceMoveMarkers: true }])
|
|
const newVal = editor.getModel().getValue()
|
|
onChange(newVal)
|
|
return
|
|
}
|
|
|
|
if (mode === 'prepend') {
|
|
onChange(insertText + value)
|
|
} else {
|
|
onChange(value + insertText)
|
|
}
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Insert variable reference failed', err)
|
|
}
|
|
},
|
|
[onChange, value]
|
|
)
|
|
|
|
return (
|
|
<BaseNode className="w-80">
|
|
<BaseNodeHeader className="border-b">
|
|
<Pencil className="size-4" />
|
|
<BaseNodeHeaderTitle>{id}.yml</BaseNodeHeaderTitle>
|
|
</BaseNodeHeader>
|
|
|
|
<BaseNodeContent>
|
|
{hasDependencies && (
|
|
<div className="w-full">
|
|
<Menubar className="h-auto bg-none p-1 border-none shadow-none">
|
|
<MenubarMenu>
|
|
<MenubarTrigger className="px-1.5 py-0 text-xs">
|
|
<GitBranchPlus className="size-3.5" />
|
|
</MenubarTrigger>
|
|
<MenubarContent className="min-w-[12rem]">
|
|
{connectedConfigNodes.map((n: any) => (
|
|
<MenubarSub key={`config-${n.id}`}>
|
|
<MenubarSubTrigger className="text-xs">
|
|
{n.data?.title ?? n.id}
|
|
</MenubarSubTrigger>
|
|
<MenubarSubContent>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertIncludeFromNode(n, 'prepend')}
|
|
>
|
|
Prepend include
|
|
</MenubarItem>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertIncludeFromNode(n, 'append')}
|
|
>
|
|
Append include
|
|
</MenubarItem>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertIncludeFromNode(n, 'cursor')}
|
|
>
|
|
Insert at cursor
|
|
</MenubarItem>
|
|
</MenubarSubContent>
|
|
</MenubarSub>
|
|
))}
|
|
{connectedVariableNodes.map((n: any) => (
|
|
<MenubarSub key={`var-${n.id}`}>
|
|
<MenubarSubTrigger className="text-xs">
|
|
{n.id}
|
|
</MenubarSubTrigger>
|
|
<MenubarSubContent>
|
|
<MenubarItem
|
|
className="text-xs"
|
|
onClick={() => insertVariableReference(n, 'cursor')}
|
|
>
|
|
Insert at cursor
|
|
</MenubarItem>
|
|
</MenubarSubContent>
|
|
</MenubarSub>
|
|
))}
|
|
</MenubarContent>
|
|
</MenubarMenu>
|
|
</Menubar>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ height: 180 }} className="w-full nodrag nopan">
|
|
<Editor
|
|
height="100%"
|
|
defaultLanguage="yaml"
|
|
value={value}
|
|
theme="vs-light"
|
|
onMount={handleEditorMount}
|
|
onChange={onChange}
|
|
options={{
|
|
minimap: { enabled: false },
|
|
fontSize: 12,
|
|
lineHeight: 16,
|
|
glyphMargin: false,
|
|
renderLineHighlight: 'none',
|
|
lineDecorationsWidth: 2,
|
|
padding: { top: 0, bottom: 4 },
|
|
lineNumbers: 'on',
|
|
}}
|
|
/>
|
|
</div>
|
|
</BaseNodeContent>
|
|
|
|
<BaseNodeFooter>
|
|
<div className="w-full text-xs text-gray-500">{storedYaml ? `${storedYaml.length} chars` : 'none'}</div>
|
|
</BaseNodeFooter>
|
|
|
|
<InputHandle id="ain" />
|
|
<OutputHandle id="out" />
|
|
</BaseNode>
|
|
)
|
|
})
|
|
|
|
ConfigNode.displayName = 'ConfigNode'
|
|
|
|
export default ConfigNode
|