diff --git a/src/main/frontend/app/actions/navigationActions.ts b/src/main/frontend/app/actions/navigationActions.ts index ccdf3a5f..ef67b250 100644 --- a/src/main/frontend/app/actions/navigationActions.ts +++ b/src/main/frontend/app/actions/navigationActions.ts @@ -33,3 +33,47 @@ export function openInEditor(relativePath: string, filepath: string) { setActiveTab(filepath) useNavigationStore.getState().navigate('editor') } + +export function openInEditorAtElement(subtype: string, name: string | undefined, filepath: string) { + const editorStore = useEditorTabStore.getState() + const fileName = filepath.split(/[/\\]/).pop() ?? filepath + + if (!editorStore.getTab(filepath)) { + editorStore.setTabData(filepath, { + name: fileName, + configurationPath: filepath, + }) + } + + editorStore.setPendingHighlight({ subtype, name }) + editorStore.setActiveTab(filepath) + useNavigationStore.getState().navigate('editor') +} + +export function openInStudioAtNode( + adapterName: string, + filepath: string, + adapterPosition: number, + subtype: string, + name: string, +) { + const { setTabData, setActiveTab, getTab } = useTabStore.getState() + + const tabId = `${filepath}::${adapterName}::${adapterPosition}` + + const existing = getTab(tabId) + if (existing) { + setTabData(tabId, { ...existing, pendingNodeSelection: { subtype, name } }) + } else { + setTabData(tabId, { + name: adapterName, + configurationPath: filepath, + adapterPosition, + flowJson: {}, + pendingNodeSelection: { subtype, name }, + }) + } + + setActiveTab(tabId) + useNavigationStore.getState().navigate('studio') +} diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index fc5078fe..4cb7fd08 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -139,6 +139,30 @@ body { @apply border-l-4 border-yellow-400 bg-yellow-200/30 transition-colors; } +.monaco-editor .frank-node-glyph { + cursor: pointer !important; + display: flex !important; + align-items: center; + justify-content: center; + opacity: 0.6; + transition: opacity 0.15s; +} + +.monaco-editor .frank-node-glyph:hover { + opacity: 1; +} + +.monaco-editor .frank-node-glyph::after { + content: ''; + display: block; + width: 13px; + height: 13px; + margin-left: 5px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect x='1' y='1' width='14' height='14' rx='3' fill='none' stroke='%23888' stroke-width='1.5'/%3E%3Cpath d='M5.5 5.5l5 2.5-5 2.5V5.5z' fill='%23888'/%3E%3C/svg%3E"); + background-size: 13px 13px; + background-repeat: no-repeat; +} + .monaco-editor .xml-lint.xml-lint--fatal-error { border-color: #ff2424; } diff --git a/src/main/frontend/app/components/flow/canvas-context-menu.tsx b/src/main/frontend/app/components/flow/canvas-context-menu.tsx index a01abaff..0892cd54 100644 --- a/src/main/frontend/app/components/flow/canvas-context-menu.tsx +++ b/src/main/frontend/app/components/flow/canvas-context-menu.tsx @@ -12,9 +12,11 @@ interface CanvasContextMenuProps { onCut: () => void onCopy: () => void onPaste: () => void + onShowInEditor: () => void hasSelection: boolean hasGroupedSelection: boolean hasClipboard: boolean + hasSingleNodeSelection: boolean } function formatShortcut(shortcutId: string): string | null { @@ -33,9 +35,11 @@ export default function CanvasContextMenu({ onCut, onCopy, onPaste, + onShowInEditor, hasSelection, hasGroupedSelection, hasClipboard, + hasSingleNodeSelection, }: CanvasContextMenuProps) { const menuRef = useRef(null) useContextMenuDismiss(menuRef, onClose) @@ -71,6 +75,8 @@ export default function CanvasContextMenu({ > {menuItem('Add Note', onAddNote, true)}
+ {menuItem('Show in Editor', onShowInEditor, hasSingleNodeSelection, 'studio.show-in-editor')} +
{menuItem('Group', onGroup, hasSelection, 'studio.group')} {menuItem('Ungroup', onUngroup, hasGroupedSelection, 'studio.ungroup')}
diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 3f269612..6432e375 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -36,10 +36,13 @@ import { extractFlowElements, findAdapterIndexAtOffset, findAdaptersInXml, + findElementRangeInXml, + findFrankElementsForGlyphs, findFlowElementsStartLine, lineToOffset, wrapFlowXml, } from './xml-utils' +import { openInStudioAtNode } from '~/actions/navigationActions' type LeftTab = 'files' | 'git' type SaveStatus = 'idle' | 'saving' | 'saved' @@ -255,12 +258,19 @@ export default function CodeEditor() { const xsdContentRef = useRef(null) const errorDecorationsRef = useRef<{ clear: () => void } | null>(null) const flowDecorationsRef = useRef(null) + const highlightDecorationsRef = useRef(null) + const frankGlyphsDecorationsRef = useRef(null) + const frankElementsRef = useRef>([]) const debounceTimerRef = useRef | null>(null) const savedTimerRef = useRef | null>(null) const validationTimerRef = useRef | null>(null) const validationCounterRef = useRef(0) const contentCacheRef = useRef>(new Map()) + const [pendingHighlight, setPendingHighlightLocal] = useState<{ subtype: string; name?: string } | null>( + () => useEditorTabStore.getState().pendingHighlight, + ) + const activeTab = useEditorTabStore( useShallow((state) => { const tab = state.activeTabFilePath ? state.tabs[state.activeTabFilePath] : undefined @@ -301,6 +311,31 @@ export default function CodeEditor() { } }, [fileLanguage]) + const applyFrankGlyphs = useCallback( + (content: string) => { + const editor = editorReference.current + if (!editor || fileLanguage !== 'xml') return + + const elements = findFrankElementsForGlyphs(content) + frankElementsRef.current = elements + + const decorations = elements.map((element) => ({ + range: { startLineNumber: element.startLine, startColumn: 1, endLineNumber: element.startLine, endColumn: 1 }, + options: { + glyphMarginClassName: 'frank-node-glyph', + glyphMarginHoverMessage: { value: `Open **${element.name}** in Studio` }, + }, + })) + + if (frankGlyphsDecorationsRef.current) { + frankGlyphsDecorationsRef.current.set(decorations) + } else if (decorations.length > 0) { + frankGlyphsDecorationsRef.current = editor.createDecorationsCollection(decorations) + } + }, + [fileLanguage], + ) + const performSave = useCallback( (content?: string) => { if (!project || !activeTabFilePath || isDiffTab) return @@ -482,8 +517,11 @@ export default function CodeEditor() { const handleEditorMount: OnMount = (editor, monacoInstance) => { editorReference.current = editor monacoReference.current = monacoInstance + frankGlyphsDecorationsRef.current = null setEditorMounted(true) + editor.updateOptions({ glyphMargin: true }) + applyFlowHighlighter() editor.addAction({ @@ -513,6 +551,32 @@ export default function CodeEditor() { ], run: runReformat, }) + + editor.onMouseDown((event) => { + if (highlightDecorationsRef.current) { + highlightDecorationsRef.current.clear() + highlightDecorationsRef.current = null + } + + if (event.target.type === monacoInstance.editor.MouseTargetType.GUTTER_GLYPH_MARGIN) { + const lineNumber = event.target.position?.lineNumber + if (!lineNumber) return + + const editorTab = useEditorTabStore.getState().getTab(useEditorTabStore.getState().activeTabFilePath) + if (!editorTab) return + + const element = frankElementsRef.current.find((element) => element.startLine === lineNumber) + if (!element) return + + openInStudioAtNode( + element.adapterName, + editorTab.configurationPath, + element.adapterPosition, + element.subtype, + element.name, + ) + } + }) } useEffect(() => { @@ -591,10 +655,13 @@ export default function CodeEditor() { errorDecorationsRef.current.clear() errorDecorationsRef.current = null } - // Also clear flow decorations when switching files if (flowDecorationsRef.current) { flowDecorationsRef.current.set([]) } + if (frankGlyphsDecorationsRef.current) { + frankGlyphsDecorationsRef.current.clear() + frankGlyphsDecorationsRef.current = null + } const monaco = monacoReference.current const editor = editorReference.current if (monaco && editor) { @@ -606,9 +673,14 @@ export default function CodeEditor() { useEffect(() => { if (!fileContent || !xsdLoaded || isDiffTab || fileLanguage !== 'xml') return runSchemaValidation(fileContent) - applyFlowHighlighter() // Refresh highlighter when schema is loaded or content changes + applyFlowHighlighter() }, [fileContent, xsdLoaded, isDiffTab, runSchemaValidation, fileLanguage, applyFlowHighlighter]) + useEffect(() => { + if (!fileContent || !editorMounted || isDiffTab || fileLanguage !== 'xml') return + applyFrankGlyphs(fileContent) + }, [fileContent, editorMounted, isDiffTab, fileLanguage, applyFrankGlyphs]) + useEffect(() => { if (!fileContent || !activeTabFilePath || !editorReference.current || isDiffTab) return @@ -636,6 +708,37 @@ export default function CodeEditor() { return () => clearTimeout(timeout) }, [fileContent, activeTabFilePath, isDiffTab]) + useEffect(() => { + return useEditorTabStore.subscribe( + (state) => state.pendingHighlight, + (highlight) => setPendingHighlightLocal(highlight), + ) + }, []) + + useEffect(() => { + if (!pendingHighlight || !fileContent || !editorReference.current || isDiffTab) return + + const editor = editorReference.current + const range = findElementRangeInXml(fileContent, pendingHighlight.subtype, pendingHighlight.name) + + useEditorTabStore.getState().setPendingHighlight(null) + + if (!range) return + + editor.revealLineNearTop(range.startLine) + editor.setPosition({ lineNumber: range.startLine, column: 1 }) + editor.focus() + + highlightDecorationsRef.current?.clear() + + highlightDecorationsRef.current = editor.createDecorationsCollection([ + { + range: { startLineNumber: range.startLine, startColumn: 1, endLineNumber: range.endLine, endColumn: 1 }, + options: { isWholeLine: true, className: 'highlight-line' }, + }, + ]) + }, [pendingHighlight, fileContent, isDiffTab, editorMounted]) + const handleOpenInStudio = useCallback(() => { const editorTab = useEditorTabStore.getState().getTab(activeTabFilePath) if (!editorTab) return @@ -737,7 +840,7 @@ export default function CodeEditor() { scheduleSave() if (value && fileLanguage === 'xml') { scheduleSchemaValidation(value) - applyFlowHighlighter() // Real-time highlight updates + applyFlowHighlighter() } }} options={{ @@ -746,6 +849,7 @@ export default function CodeEditor() { tabSize: 2, insertSpaces: true, detectIndentation: false, + glyphMargin: true, }} />
diff --git a/src/main/frontend/app/routes/editor/xml-utils.ts b/src/main/frontend/app/routes/editor/xml-utils.ts index 2081b119..6ce99305 100644 --- a/src/main/frontend/app/routes/editor/xml-utils.ts +++ b/src/main/frontend/app/routes/editor/xml-utils.ts @@ -1,24 +1,144 @@ -interface AdapterLocation { +export interface AdapterLocation { name: string offset: number } +export interface FrankElementLocation { + subtype: string + name: string + startLine: number + adapterName: string + adapterPosition: number +} + +const STRUCTURAL_TAGS = new Set([ + 'Adapter', + 'Configuration', + 'Module', + 'Pipeline', + 'Exits', + 'Forwards', + 'Global-forwards', + 'GlobalForwards', + 'PipelinePart', + 'Root', + 'Scheduler', +]) + +const MAX_LOOKAHEAD_LINES = 15 +const MAX_TAG_LINES = 50 + +const REGEX_ADAPTER = /<([A-Za-z0-9_:-]+:)?adapter\b[^>]*\bname\s*=\s*["']([^"']*)["']/gi +const REGEX_OPEN_TAG = /^\s*<([A-Za-z0-9_:-]+)/ +const REGEX_CLOSE_TAG = /<\/([A-Za-z0-9_:-]+)>/g +const REGEX_NAME_ATTR = /\bname=["']([^"']*)["']/ +const REGEX_FLOW_ELEMENTS = // +const REGEX_NEW_TAG_START = /^\s*<[A-Za-z]/ + +function getLocalName(tag: string): string { + const colonIndex = tag.indexOf(':') + return colonIndex === -1 ? tag : tag.slice(colonIndex + 1) +} + +function toPascalCase(tag: string): string { + return tag.charAt(0).toUpperCase() + tag.slice(1) +} + +function analyzeTagStructure(lines: string[], startLine: number): { isSelfClosing: boolean; endLine: number } { + let isInsideString = false + let stringDelimiter = '' + let isInsideTag = false + let previousChar = '' + + const searchLimit = Math.min(startLine + MAX_TAG_LINES, lines.length) + + for (let currentLine = startLine; currentLine < searchLimit; currentLine++) { + for (const char of lines[currentLine]) { + if (!isInsideTag) { + if (char === '<') isInsideTag = true + continue + } + + if (isInsideString) { + if (char === stringDelimiter) isInsideString = false + previousChar = char + continue + } + + if (char === '"' || char === "'") { + isInsideString = true + stringDelimiter = char + previousChar = char + continue + } + + if (char === '>') { + return { isSelfClosing: previousChar === '/', endLine: currentLine + 1 } + } + + if (char.trim() !== '') { + previousChar = char + } + } + } + + return { isSelfClosing: false, endLine: startLine + 1 } +} + +/** + * Looks for a `name="…"` attribute within the next few lines after `lineIndex`. + * Stops early when the tag clearly ends. + */ +function extractNameAttribute(lines: string[], startLine: number): string | null { + const searchLimit = Math.min(startLine + MAX_LOOKAHEAD_LINES, lines.length) + + for (let i = startLine; i < searchLimit; i++) { + const match = lines[i].match(REGEX_NAME_ATTR) + if (match) return match[1] + + const isTagEnding = lines[i].includes('/>') || (/[^/]>\s*$/.test(lines[i]) && i > startLine) + if (isTagEnding) break + } + + return null +} + +function hasNameAttributeWithinTag(lines: string[], startLine: number, targetName: string): boolean { + const searchLimit = Math.min(startLine + MAX_LOOKAHEAD_LINES, lines.length) + + for (let i = startLine; i < searchLimit; i++) { + if (lines[i].includes(`name="${targetName}"`) || lines[i].includes(`name='${targetName}'`)) { + return true + } + + if (i > startLine && REGEX_NEW_TAG_START.test(lines[i])) { + return false + } + } + return false +} + export function findAdaptersInXml(xml: string): AdapterLocation[] { const adapters: AdapterLocation[] = [] - const regex = /]*\bname\s*=\s*"([^"]*)"/gi let match: RegExpExecArray | null - while ((match = regex.exec(xml)) !== null) { - adapters.push({ name: match[1], offset: match.index }) + + REGEX_ADAPTER.lastIndex = 0 + + while ((match = REGEX_ADAPTER.exec(xml)) !== null) { + adapters.push({ name: match[2], offset: match.index }) } + return adapters } export function lineToOffset(xml: string, lineNumber: number): number { const lines = xml.split('\n') let offset = 0 + for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) { offset += lines[i].length + 1 } + return offset } @@ -30,23 +150,125 @@ export function findAdapterIndexAtOffset(adapters: AdapterLocation[], cursorOffs } export function extractFlowElements(xml: string): string | null { - const match = xml.match(//) + const match = xml.match(REGEX_FLOW_ELEMENTS) return match ? match[0] : null } export function wrapFlowXml(fragment: string): string { - const inner = fragment + const innerContent = fragment .replace(/^]*>/, '') - .replace(/<\/flow:FlowElements>$/, '') + .replace('', '') .trim() - return `${inner}` + return `${innerContent}` } export function findFlowElementsStartLine(xml: string): number { const lines = xml.split('\n') - for (const [i, line] of lines.entries()) { - if (line.includes(' line.includes('= elementOffset) return null + + return { + subtype: tag, + name, + startLine: lineIndex + 1, + adapterName: adapter.name, + adapterPosition: adapterIndex, + } +} + +export function findFrankElementsForGlyphs(xml: string): FrankElementLocation[] { + const adapters = findAdaptersInXml(xml) + if (adapters.length === 0) return [] + + const lines = xml.split('\n') + const results: FrankElementLocation[] = [] + const tagStack: string[] = [] + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex] + + const openMatch = line.match(REGEX_OPEN_TAG) + if (openMatch) { + const rawTag = openMatch[1] + const baseTagName = getLocalName(rawTag) + const pascalTag = toPascalCase(baseTagName) + const parentTag = (tagStack.at(-1) ?? '').toLowerCase() + + if (isGlyphNode(pascalTag, parentTag)) { + const entry = createGlyphEntry(pascalTag, lineIndex, lines, xml, adapters) + if (entry) results.push(entry) + } + + const { isSelfClosing } = analyzeTagStructure(lines, lineIndex) + if (!isSelfClosing) { + tagStack.push(baseTagName) + } + } + + processClosingTags(line, tagStack) + } + + return results } diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index f9438836..56343cee 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -48,6 +48,7 @@ import { useSettingsStore } from '~/stores/settings-store' import { useShortcut } from '~/hooks/use-shortcut' import CanvasContextMenu from '~/components/flow/canvas-context-menu' import { useSidebarStore, SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' +import { openInEditorAtElement } from '~/actions/navigationActions' export type FlowNode = FrankNodeType | ExitNode | StickyNote | GroupNode | Node @@ -88,6 +89,8 @@ const distanceToFrankNode = (sticky: StickyNote, frankNode: FlowNode) => { return Math.hypot(dx, dy) } +const isFrankNode = (node: FlowNode): node is FrankNodeType => node.type === 'frankNode' || node.type === 'exitNode' + const findNearestFrankNode = (sticky: StickyNote, candidates: FlowNode[]) => candidates .filter((n) => (n.type === 'frankNode' || n.type === 'exitNode') && isWithinSnapDistance(sticky, n)) @@ -142,6 +145,17 @@ function FlowCanvas() { })), ) const { elements } = useFFDoc() + const elementsRef = useRef(elements) + const showNodeContextMenuRef = useRef(showNodeContextMenu) + + useEffect(() => { + elementsRef.current = elements + }, [elements]) + + useEffect(() => { + showNodeContextMenuRef.current = showNodeContextMenu + }, [showNodeContextMenu]) + const [showModal, setShowModal] = useState(false) const [edgeDropPositions, setEdgeDropPositions] = useState<{ x: number; y: number } | null>(null) const clipboardRef = useRef<{ @@ -162,6 +176,40 @@ function FlowCanvas() { const reactFlowRef = useRef(reactFlow) reactFlowRef.current = reactFlow + const applySelectionToNodes = useCallback((pendingSelection: { subtype: string; name: string }) => { + const currentNodes = useFlowStore.getState().nodes + const nodeToSelect = currentNodes.find( + (node): node is FrankNodeType => + isFrankNode(node) && node.data.subtype === pendingSelection.subtype && node.data.name === pendingSelection.name, + ) + + if (!nodeToSelect) return + + useFlowStore.getState().setNodes( + currentNodes.map((node) => ({ + ...node, + selected: node.id === nodeToSelect.id, + })), + ) + + setTimeout(() => { + reactFlowRef.current?.fitView({ + nodes: [{ id: nodeToSelect.id }], + padding: 0.5, + duration: 400, + }) + }, 50) + + const nodeContextStore = useNodeContextStore.getState() + nodeContextStore.setParentId(null) + nodeContextStore.setChildParentId(null) + nodeContextStore.setNodeId(+nodeToSelect.id) + nodeContextStore.setAttributes(elementsRef.current?.[nodeToSelect.data.subtype]?.attributes) + nodeContextStore.setEditingSubtype(nodeToSelect.data.subtype) + nodeContextStore.setIsEditing(true) + showNodeContextMenuRef.current(true) + }, []) + const { nodes, edges, viewport, onNodesChange, onEdgesChange, onConnect, onReconnect } = useFlowStore( useShallow(selector), ) @@ -595,10 +643,9 @@ function FlowCanvas() { 'studio.save': () => void saveFlow(), 'studio.close-context': () => closeEditNodeContextOnEscape(), 'studio.delete': () => deleteSelection(), + 'studio.show-in-editor': () => showSelectedNodeInEditor(), }) - const isFrankNode = (node: FlowNode): node is FrankNodeType => node.type === 'frankNode' || node.type === 'exitNode' - const handleNodeDragStop = useCallback((_event: React.MouseEvent, node: FlowNode) => { if (!isStickyNote(node)) return @@ -709,7 +756,7 @@ function FlowCanvas() { const handleSelectionChange = useCallback( ({ nodes: selectedNodes }: { nodes: FlowNode[] }) => { - const frankNodes = selectedNodes.filter((n) => isFrankNode(n)) + const frankNodes = selectedNodes.filter((node) => isFrankNode(node)) if (frankNodes.length > 1) { setIsMultiSelect(true) @@ -943,6 +990,20 @@ function FlowCanvas() { handleDegroupSingleGroup(selectedNodes) }, [allSelectedInSameGroup, handleDegroupSingleGroup, degroupNodes]) + const showSelectedNodeInEditor = useCallback(() => { + const flowStore = useFlowStore.getState() + const selectedFrankNodes = flowStore.nodes.filter( + (node) => node.selected && node.type === 'frankNode', + ) as FrankNodeType[] + if (selectedFrankNodes.length !== 1) return + + const node = selectedFrankNodes[0] + const tabData = useTabStore.getState().getTab(useTabStore.getState().activeTab) + if (!tabData?.configurationPath) return + + openInEditorAtElement(node.data.subtype, node.data.name || undefined, tabData.configurationPath) + }, []) + const handleRightMouseButtonClick = useCallback( (event: React.MouseEvent) => { event.preventDefault() @@ -981,6 +1042,9 @@ function FlowCanvas() { const currentProject = useProjectStore.getState().project isLoadingTabRef.current = true setLoading(true) + + const pendingSelection = tab.pendingNodeSelection ?? null + try { if (tab.flowJson && Object.keys(tab.flowJson).length > 0) { restoreFlowFromTab(tab) @@ -994,20 +1058,32 @@ function FlowCanvas() { ) if (!adapter) return const adapterJson = await convertAdapterXmlToJson(adapter) + flowStore.setEdges(adapterJson.edges) flowStore.setViewport({ x: 0, y: 0, zoom: 1 }) + const laidOutNodes = layoutGraph(adapterJson.nodes, adapterJson.edges, 'LR') flowStore.setNodes(laidOutNodes) - flowStore.setHistory([]) - flowStore.setFuture([]) + } + + if (pendingSelection) { + const tabStore = useTabStore.getState() + tabStore.setTabData(tabStore.activeTab, { ...tab, pendingNodeSelection: null }) + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + applySelectionToNodes(pendingSelection) + setLoading(false) + }) + }) + } else { + setLoading(false) } } catch (error) { console.error('Error loading tab flow:', error) - } finally { setLoading(false) - setTimeout(() => { - isLoadingTabRef.current = false - }, 0) + } finally { + isLoadingTabRef.current = false } } @@ -1221,9 +1297,11 @@ function FlowCanvas() { onCut={cutSelection} onCopy={copySelection} onPaste={pasteSelection} + onShowInEditor={showSelectedNodeInEditor} hasSelection={nodes.some((n) => n.selected)} hasGroupedSelection={nodes.some((n) => n.selected) && allSelectedInSameGroup(nodes.filter((n) => n.selected))} hasClipboard={clipboardRef.current !== null} + hasSingleNodeSelection={nodes.filter((node) => node.selected && node.type === 'frankNode').length === 1} /> )}
diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx index 1a994b1d..0eb1731f 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx @@ -492,9 +492,7 @@ export default function FrankNode(properties: NodeProps) { {properties.data.attributes && Object.entries(properties.data.attributes).map(([key, value]) => (
-

- {key} -

+

{key}

{value}

))} diff --git a/src/main/frontend/app/routes/studio/context/node-context.tsx b/src/main/frontend/app/routes/studio/context/node-context.tsx index 7ea5d6a9..53360d7e 100644 --- a/src/main/frontend/app/routes/studio/context/node-context.tsx +++ b/src/main/frontend/app/routes/studio/context/node-context.tsx @@ -8,6 +8,9 @@ import ContextInput from './context-input' import { findChildRecursive } from '~/stores/child-utilities' import { useFFDoc } from '@frankframework/doc-library-react' import type { Attribute } from '@frankframework/doc-library-core' +import useTabStore from '~/stores/tab-store' +import { openInEditorAtElement } from '~/actions/navigationActions' +import CodeIcon from '/icons/solar/Code.svg?react' export default function NodeContext({ nodeId, @@ -36,6 +39,7 @@ export default function NodeContext({ setChildParentId, childParentId, setIsDirty, + editingSubtype, } = useNodeContextStore( useShallow((s) => ({ attributes: s.attributes, @@ -47,6 +51,7 @@ export default function NodeContext({ setChildParentId: s.setChildParentId, childParentId: s.childParentId, setIsDirty: s.setIsDirty, + editingSubtype: s.editingSubtype, })), ) @@ -291,6 +296,13 @@ export default function NodeContext({ setShowNodeContext(false) } + const handleShowInEditor = useCallback(() => { + const tabData = useTabStore.getState().getTab(useTabStore.getState().activeTab) + if (!tabData?.configurationPath || !editingSubtype) return + const nodeName = inputValues['name'] + openInEditorAtElement(editingSubtype, nodeName || undefined, tabData.configurationPath) + }, [editingSubtype, inputValues]) + // Build sorted attribute list: mandatory first, then initially-filled, then rest const entriesWithIndex: [string, Attribute, number][] = attributes ? Object.entries(attributes).map(([k, v], index) => [k, v as Attribute, index]) @@ -372,9 +384,15 @@ export default function NodeContext({ Save & Close - +
+ + + +
{!canSave && errorMessage &&

{errorMessage}

} diff --git a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts index f50eb354..cf3730f2 100644 --- a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts +++ b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts @@ -563,7 +563,7 @@ function convertElementToNode(element: Element, idCounter: IdCounter, sourceHand return { id: thisId, type: 'frankNode', - position: {x, y}, + position: { x, y }, width, height, data: { diff --git a/src/main/frontend/app/stores/editor-tab-store.ts b/src/main/frontend/app/stores/editor-tab-store.ts index ca239dc9..db6bd359 100644 --- a/src/main/frontend/app/stores/editor-tab-store.ts +++ b/src/main/frontend/app/stores/editor-tab-store.ts @@ -16,10 +16,16 @@ export interface EditorTabData { diffData?: DiffTabData } +export interface PendingHighlight { + subtype: string + name?: string +} + interface EditorTabStoreState { tabs: Record activeTabFilePath: string refreshCounter: number + pendingHighlight: PendingHighlight | null setTabData: (tabId: string, data: EditorTabData) => void getTab: (tabId: string) => EditorTabData | undefined setActiveTab: (tabId: string) => void @@ -27,6 +33,7 @@ interface EditorTabStoreState { removeTabAndSelectFallback: (tabId: string) => void clearTabs: () => void refreshAllTabs: () => void + setPendingHighlight: (highlight: PendingHighlight | null) => void } const useEditorTabStore = create()( @@ -34,6 +41,7 @@ const useEditorTabStore = create()( tabs: {}, activeTabFilePath: '', refreshCounter: 0, + pendingHighlight: null, setTabData: (tabId, data) => set((state) => ({ tabs: { @@ -66,6 +74,7 @@ const useEditorTabStore = create()( }), clearTabs: () => set({ tabs: {}, activeTabFilePath: '' }), refreshAllTabs: () => set((state) => ({ refreshCounter: state.refreshCounter + 1 })), + setPendingHighlight: (highlight) => set({ pendingHighlight: highlight }), })), ) diff --git a/src/main/frontend/app/stores/shortcut-store.ts b/src/main/frontend/app/stores/shortcut-store.ts index dff12040..f607f839 100644 --- a/src/main/frontend/app/stores/shortcut-store.ts +++ b/src/main/frontend/app/stores/shortcut-store.ts @@ -99,6 +99,13 @@ export const ALL_SHORTCUTS: Omit[] = [ modifiers: { cmdOrCtrl: true }, allowInInput: true, }, + { + id: 'studio.show-in-editor', + label: 'Show in Editor', + scope: 'studio', + key: 'e', + modifiers: { cmdOrCtrl: true, shift: true }, + }, // Editor { diff --git a/src/main/frontend/app/stores/tab-store.ts b/src/main/frontend/app/stores/tab-store.ts index a503de4c..0430822d 100644 --- a/src/main/frontend/app/stores/tab-store.ts +++ b/src/main/frontend/app/stores/tab-store.ts @@ -10,6 +10,7 @@ export interface TabData { adapterPosition?: number history?: FlowSnapshot[] future?: FlowSnapshot[] + pendingNodeSelection?: { subtype: string; name: string } | null } interface TabStoreState {