From 0e432777e504ff6888b1be7bb00954708e2668b6 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 6 May 2026 12:19:02 +0200 Subject: [PATCH 01/11] Add glyph margin for Frank elements in the editor and implement "Show in Editor" functionality --- .../frontend/app/actions/navigationActions.ts | 45 +++++ src/main/frontend/app/app.css | 24 +++ .../components/flow/canvas-context-menu.tsx | 6 + .../frontend/app/routes/editor/editor.tsx | 110 +++++++++++- .../frontend/app/routes/editor/xml-utils.ts | 159 ++++++++++++++++++ .../app/routes/studio/canvas/flow.tsx | 100 ++++++++++- .../routes/studio/context/node-context.tsx | 24 ++- .../frontend/app/stores/editor-tab-store.ts | 9 + .../frontend/app/stores/shortcut-store.ts | 7 + src/main/frontend/app/stores/tab-store.ts | 1 + 10 files changed, 478 insertions(+), 7 deletions(-) diff --git a/src/main/frontend/app/actions/navigationActions.ts b/src/main/frontend/app/actions/navigationActions.ts index ccdf3a5f..ed1c5c0e 100644 --- a/src/main/frontend/app/actions/navigationActions.ts +++ b/src/main/frontend/app/actions/navigationActions.ts @@ -33,3 +33,48 @@ export function openInEditor(relativePath: string, filepath: string) { setActiveTab(filepath) useNavigationStore.getState().navigate('editor') } + +export function openInEditorAtElement(subtype: string, name: string | undefined, filepath: string) { + const { setTabData, setActiveTab, getTab, setPendingHighlight } = useEditorTabStore.getState() + + const fileName = filepath.split(/[/\\]/).pop() ?? filepath + + if (!getTab(filepath)) { + setTabData(filepath, { + name: fileName, + configurationPath: filepath, + }) + } + + setPendingHighlight({ subtype, name }) + 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..000c35e5 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,18 @@ 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 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 +310,30 @@ export default function CodeEditor() { } }, [fileLanguage]) + const applyFrankGlyphs = useCallback( + (content: string) => { + const editor = editorReference.current + if (!editor || fileLanguage !== 'xml') return + + const elements = findFrankElementsForGlyphs(content) + + 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 @@ -484,6 +517,8 @@ export default function CodeEditor() { monacoReference.current = monacoInstance setEditorMounted(true) + editor.updateOptions({ glyphMargin: true }) + applyFlowHighlighter() editor.addAction({ @@ -513,6 +548,34 @@ 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 content = editor.getValue() + const editorTab = useEditorTabStore.getState().getTab(useEditorTabStore.getState().activeTabFilePath) + if (!editorTab) return + + const elements = findFrankElementsForGlyphs(content) + const element = elements.find((element) => element.startLine === lineNumber) + if (!element) return + + openInStudioAtNode( + element.adapterName, + editorTab.configurationPath, + element.adapterPosition, + element.subtype, + element.name, + ) + } + }) } useEffect(() => { @@ -591,10 +654,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 +672,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 +707,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]) + const handleOpenInStudio = useCallback(() => { const editorTab = useEditorTabStore.getState().getTab(activeTabFilePath) if (!editorTab) return @@ -737,7 +839,8 @@ export default function CodeEditor() { scheduleSave() if (value && fileLanguage === 'xml') { scheduleSchemaValidation(value) - applyFlowHighlighter() // Real-time highlight updates + applyFlowHighlighter() + applyFrankGlyphs(value) } }} 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..7b675362 100644 --- a/src/main/frontend/app/routes/editor/xml-utils.ts +++ b/src/main/frontend/app/routes/editor/xml-utils.ts @@ -3,6 +3,14 @@ interface AdapterLocation { offset: number } +export interface FrankElementLocation { + subtype: string + name: string + startLine: number + adapterName: string + adapterPosition: number +} + export function findAdaptersInXml(xml: string): AdapterLocation[] { const adapters: AdapterLocation[] = [] const regex = /]*\bname\s*=\s*"([^"]*)"/gi @@ -50,3 +58,154 @@ export function findFlowElementsStartLine(xml: string): number { } return 1 } + +export function findElementInXml(xml: string, subtype: string, name?: string): number | null { + const lines = xml.split('\n') + + if (name) { + for (const [i, line] of lines.entries()) { + if (line.includes(`<${subtype}`) && line.includes(`name="${name}"`)) { + return i + 1 + } + } + + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(`<${subtype}`)) { + for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) { + if (lines[j].includes(`name="${name}"`)) { + return i + 1 + } + if (/^\s*<[A-Za-z]/.test(lines[j])) break + } + } + } + } + + for (const [i, line] of lines.entries()) { + if (line.includes(`<${subtype}`)) { + return i + 1 + } + } + + return null +} + +export function findElementRangeInXml( + xml: string, + subtype: string, + name?: string, +): { startLine: number; endLine: number } | null { + const startLine = findElementInXml(xml, subtype, name) + if (!startLine) return null + + const lines = xml.split('\n') + const endLine = findOpenTagEndLine(lines, startLine - 1) + return { startLine, endLine } +} + +const STRUCTURAL_TAGS = new Set(['Adapter', 'Configuration', 'Module', 'Pipeline', 'Exits']) + +function toPascalCase(tag: string): string { + return tag[0].toUpperCase() + tag.slice(1) +} + +function isStudioFlowNode(tag: string, parent: string): boolean { + if (STRUCTURAL_TAGS.has(tag)) return false + return (parent === 'adapter' && tag === 'Receiver') || (parent === 'pipeline' && tag !== 'Exit') +} + +function findOpenTagEndLine(lines: string[], startIndex: number): number { + let inString = false + let stringChar = '' + let foundOpen = false + + for (let i = startIndex; i < Math.min(startIndex + 50, lines.length); i++) { + for (const ch of lines[i]) { + if (!foundOpen) { + if (ch === '<') foundOpen = true + continue + } + if (inString) { + if (ch === stringChar) inString = false + continue + } + if (ch === '"' || ch === "'") { + inString = true + stringChar = ch + continue + } + if (ch === '>') return i + 1 + } + } + + return startIndex + 1 +} + +function isTagSelfClosing(lines: string[], lineIndex: number): boolean { + for (let i = lineIndex; i < Math.min(lineIndex + 20, lines.length); i++) { + if (lines[i].includes('/>')) return true + if (/[^/]>\s*$/.test(lines[i])) return false + } + return false +} + +function findNameNearLine(lines: string[], lineIndex: number): string | null { + for (let i = lineIndex; i < Math.min(lineIndex + 15, lines.length); i++) { + const nameMatch = lines[i].match(/\bname="([^"]*)"/) + if (nameMatch) return nameMatch[1] + if (lines[i].includes('/>') || (/[^/]>\s*$/.test(lines[i]) && i > lineIndex)) break + } + return null +} + +function buildGlyphEntry( + tag: string, + lineIndex: number, + lines: string[], + xml: string, + adapters: AdapterLocation[], +): FrankElementLocation | null { + const name = findNameNearLine(lines, lineIndex) + if (!name) return null + + const elementOffset = lineToOffset(xml, lineIndex + 1) + const adapterIndex = findAdapterIndexAtOffset(adapters, elementOffset) + const adapter = adapters[adapterIndex] + if (!adapter || adapter.offset >= elementOffset) return null + + return { subtype: tag, name, startLine: lineIndex + 1, adapterName: adapter.name, adapterPosition: adapterIndex } +} + +export function findFrankElementsForGlyphs(xml: string): FrankElementLocation[] { + const lines = xml.split('\n') + const adapters = findAdaptersInXml(xml) + if (adapters.length === 0) return [] + + const results: FrankElementLocation[] = [] + const tagStack: string[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + for (const { 1: tag } of line.matchAll(/<\/([A-Za-z][A-Za-z0-9]*)/g)) { + const idx = tagStack.lastIndexOf(tag) + if (idx !== -1) tagStack.splice(idx, 1) + } + + const openMatch = line.match(/^\s*<([A-Za-z][A-Za-z0-9]*)[\s/>]/) + if (!openMatch) continue + + const rawTag = openMatch[1] + const tag = toPascalCase(rawTag) + const parent = (tagStack.at(-1) ?? '').toLowerCase() + + if (isStudioFlowNode(tag, parent)) { + const entry = buildGlyphEntry(tag, i, lines, xml, adapters) + if (entry) results.push(entry) + } + + if (!isTagSelfClosing(lines, i)) tagStack.push(rawTag) + } + + 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..cb3ff2ac 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 @@ -142,6 +143,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 +174,30 @@ 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( + (n) => + n.type === 'frankNode' && + isFrankNode(n) && + n.data.subtype === pendingSelection.subtype && + n.data.name === pendingSelection.name, + ) as FrankNodeType | undefined + if (!nodeToSelect) return + + useFlowStore.getState().setNodes(currentNodes.map((n) => ({ ...n, selected: n.id === nodeToSelect.id }))) + reactFlowRef.current?.fitView({ nodes: [{ id: nodeToSelect.id }], padding: 0.5, duration: 0 }) + + const ncs = useNodeContextStore.getState() + ncs.setParentId(null) + ncs.setChildParentId(null) + ncs.setNodeId(+nodeToSelect.id) + ncs.setAttributes(elementsRef.current?.[nodeToSelect.data.subtype]?.attributes) + ncs.setEditingSubtype(nodeToSelect.data.subtype) + ncs.setIsEditing(true) + showNodeContextMenuRef.current(true) + }, []) + const { nodes, edges, viewport, onNodesChange, onEdgesChange, onConnect, onReconnect } = useFlowStore( useShallow(selector), ) @@ -595,6 +631,7 @@ 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' @@ -943,6 +980,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 +1032,13 @@ function FlowCanvas() { const currentProject = useProjectStore.getState().project isLoadingTabRef.current = true setLoading(true) + + const pendingSelection = tab.pendingNodeSelection ?? null + if (pendingSelection) { + const tabStore = useTabStore.getState() + tabStore.setTabData(tabStore.activeTab, { ...tab, pendingNodeSelection: null }) + } + try { if (tab.flowJson && Object.keys(tab.flowJson).length > 0) { restoreFlowFromTab(tab) @@ -1004,10 +1062,21 @@ function FlowCanvas() { } catch (error) { console.error('Error loading tab flow:', error) } finally { - setLoading(false) setTimeout(() => { isLoadingTabRef.current = false }, 0) + if (!pendingSelection) { + setLoading(false) + } + } + + if (pendingSelection) { + requestAnimationFrame(() => + requestAnimationFrame(() => { + applySelectionToNodes(pendingSelection) + setLoading(false) + }), + ) } } @@ -1062,6 +1131,33 @@ function FlowCanvas() { return () => unsubscribe() }, [layoutGraph]) + useEffect(() => { + function consumePendingSelection() { + if (isLoadingTabRef.current) return + + const tabStore = useTabStore.getState() + const tabId = tabStore.activeTab + const tab = tabStore.getTab(tabId) + const pending = tab?.pendingNodeSelection ?? null + if (!pending) return + + tabStore.setTabData(tabId, { ...tab!, pendingNodeSelection: null }) + requestAnimationFrame(() => applySelectionToNodes(pending)) + } + + consumePendingSelection() + + const unsubscribePending = useTabStore.subscribe( + (state) => state.tabs[state.activeTab]?.pendingNodeSelection ?? null, + (pendingSelection) => { + if (!pendingSelection) return + consumePendingSelection() + }, + ) + return () => unsubscribePending() + }, [applySelectionToNodes]) + + useEffect(() => { const unsub = useFlowStore.subscribe( (state) => state.nodes, @@ -1221,9 +1317,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/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/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 { From d01bd2709eb06de5963e1cc4130c1b04403d4e75 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 11 May 2026 14:42:37 +0200 Subject: [PATCH 02/11] Store Frank elements in a ref for optimized access during editor interactions --- src/main/frontend/app/routes/editor/editor.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 000c35e5..f2625975 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -260,6 +260,7 @@ export default function CodeEditor() { 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) @@ -316,6 +317,7 @@ export default function CodeEditor() { 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 }, @@ -559,12 +561,10 @@ export default function CodeEditor() { const lineNumber = event.target.position?.lineNumber if (!lineNumber) return - const content = editor.getValue() const editorTab = useEditorTabStore.getState().getTab(useEditorTabStore.getState().activeTabFilePath) if (!editorTab) return - const elements = findFrankElementsForGlyphs(content) - const element = elements.find((element) => element.startLine === lineNumber) + const element = frankElementsRef.current.find((element) => element.startLine === lineNumber) if (!element) return openInStudioAtNode( From 146b6ea7df740f7f0a09531279faf3c1456a22e2 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 11 May 2026 16:26:06 +0200 Subject: [PATCH 03/11] Refactor XML utilities and enhance glyph processing for Frank elements --- .../frontend/app/routes/editor/editor.tsx | 1 + .../frontend/app/routes/editor/xml-utils.ts | 262 +++++++++++------- 2 files changed, 156 insertions(+), 107 deletions(-) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index f2625975..5c8cb795 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -517,6 +517,7 @@ export default function CodeEditor() { const handleEditorMount: OnMount = (editor, monacoInstance) => { editorReference.current = editor monacoReference.current = monacoInstance + frankGlyphsDecorationsRef.current = null setEditorMounted(true) editor.updateOptions({ 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 7b675362..86b550f9 100644 --- a/src/main/frontend/app/routes/editor/xml-utils.ts +++ b/src/main/frontend/app/routes/editor/xml-utils.ts @@ -1,4 +1,4 @@ -interface AdapterLocation { +export interface AdapterLocation { name: string offset: number } @@ -11,22 +11,119 @@ export interface FrankElementLocation { adapterPosition: number } +const STRUCTURAL_TAGS = new Set(['Adapter', 'Configuration', 'Module', 'Pipeline', 'Exits']) +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 = // + + +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 && /^\s*<[A-Za-z]/.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 }) + + 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 } @@ -38,51 +135,35 @@ 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 - .replace(/^]*>/, '') - .replace(/<\/flow:FlowElements>$/, '') - .trim() + const innerContent = fragment + .replace(/^]*>/, '') + .replace(/<\/flow:FlowElements>$/, '') + .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('') return i + 1 - } - } - - return startIndex + 1 + return (parentTag === 'adapter' && tag === 'Receiver') || (parentTag === 'pipeline' && tag !== 'Exit') } -function isTagSelfClosing(lines: string[], lineIndex: number): boolean { - for (let i = lineIndex; i < Math.min(lineIndex + 20, lines.length); i++) { - if (lines[i].includes('/>')) return true - if (/[^/]>\s*$/.test(lines[i])) return false +function processClosingTags(line: string, stack: string[]) { + for (const { 1: rawTag } of line.matchAll(REGEX_CLOSE_TAG)) { + const tagName = getLocalName(rawTag) + const index = stack.lastIndexOf(tagName) + if (index !== -1) stack.length = index } - return false } -function findNameNearLine(lines: string[], lineIndex: number): string | null { - for (let i = lineIndex; i < Math.min(lineIndex + 15, lines.length); i++) { - const nameMatch = lines[i].match(/\bname="([^"]*)"/) - if (nameMatch) return nameMatch[1] - if (lines[i].includes('/>') || (/[^/]>\s*$/.test(lines[i]) && i > lineIndex)) break - } - return null -} - -function buildGlyphEntry( - tag: string, - lineIndex: number, - lines: string[], - xml: string, - adapters: AdapterLocation[], +function createGlyphEntry( + tag: string, + lineIndex: number, + lines: string[], + xml: string, + adapters: AdapterLocation[], ): FrankElementLocation | null { - const name = findNameNearLine(lines, lineIndex) + const name = extractNameAttribute(lines, lineIndex) if (!name) return null const elementOffset = lineToOffset(xml, lineIndex + 1) const adapterIndex = findAdapterIndexAtOffset(adapters, elementOffset) const adapter = adapters[adapterIndex] + if (!adapter || adapter.offset >= elementOffset) return null - return { subtype: tag, name, startLine: lineIndex + 1, adapterName: adapter.name, adapterPosition: adapterIndex } + return { + subtype: tag, + name, + startLine: lineIndex + 1, + adapterName: adapter.name, + adapterPosition: adapterIndex + } } export function findFrankElementsForGlyphs(xml: string): FrankElementLocation[] { - const lines = xml.split('\n') const adapters = findAdaptersInXml(xml) if (adapters.length === 0) return [] + const lines = xml.split('\n') const results: FrankElementLocation[] = [] const tagStack: string[] = [] - for (let i = 0; i < lines.length; i++) { - const line = lines[i] + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex] - for (const { 1: tag } of line.matchAll(/<\/([A-Za-z][A-Za-z0-9]*)/g)) { - const idx = tagStack.lastIndexOf(tag) - if (idx !== -1) tagStack.splice(idx, 1) - } + processClosingTags(line, tagStack) - const openMatch = line.match(/^\s*<([A-Za-z][A-Za-z0-9]*)[\s/>]/) + const openMatch = line.match(REGEX_OPEN_TAG) if (!openMatch) continue const rawTag = openMatch[1] - const tag = toPascalCase(rawTag) - const parent = (tagStack.at(-1) ?? '').toLowerCase() + const baseTagName = getLocalName(rawTag) + const pascalTag = toPascalCase(baseTagName) + const parentTag = (tagStack.at(-1) ?? '').toLowerCase() - if (isStudioFlowNode(tag, parent)) { - const entry = buildGlyphEntry(tag, i, lines, xml, adapters) + if (isGlyphNode(pascalTag, parentTag)) { + const entry = createGlyphEntry(pascalTag, lineIndex, lines, xml, adapters) if (entry) results.push(entry) } - if (!isTagSelfClosing(lines, i)) tagStack.push(rawTag) + const { isSelfClosing } = analyzeTagStructure(lines, lineIndex) + if (!isSelfClosing) { + tagStack.push(baseTagName) + } } return results From dda9a6a4435271e52dd002aed69ec56f7f481d83 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 11 May 2026 17:16:55 +0200 Subject: [PATCH 04/11] applied linting --- .../frontend/app/routes/editor/xml-utils.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/main/frontend/app/routes/editor/xml-utils.ts b/src/main/frontend/app/routes/editor/xml-utils.ts index 86b550f9..28314838 100644 --- a/src/main/frontend/app/routes/editor/xml-utils.ts +++ b/src/main/frontend/app/routes/editor/xml-utils.ts @@ -21,7 +21,6 @@ const REGEX_CLOSE_TAG = /<\/([A-Za-z0-9_:-]+)>/g const REGEX_NAME_ATTR = /\bname=["']([^"']*)["']/ const REGEX_FLOW_ELEMENTS = // - function getLocalName(tag: string): string { const colonIndex = tag.indexOf(':') return colonIndex === -1 ? tag : tag.slice(colonIndex + 1) @@ -141,17 +140,17 @@ export function extractFlowElements(xml: string): string | null { export function wrapFlowXml(fragment: string): string { const innerContent = fragment - .replace(/^]*>/, '') - .replace(/<\/flow:FlowElements>$/, '') - .trim() + .replace(/^]*>/, '') + .replace(/<\/flow:FlowElements>$/, '') + .trim() return `${innerContent}` } export function findFlowElementsStartLine(xml: string): number { const lines = xml.split('\n') - const index = lines.findIndex(line => line.includes(' line.includes(' Date: Mon, 11 May 2026 17:26:52 +0200 Subject: [PATCH 05/11] applied linting --- src/main/frontend/app/routes/studio/canvas/flow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index cb3ff2ac..efd0c035 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -746,7 +746,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) From 88049aa01d1f41aa6356765d28709eb83ebf2e83 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Tue, 12 May 2026 16:04:02 +0200 Subject: [PATCH 06/11] Enhance STRUCTURAL_TAGS set and improve tag processing logic in XML utilities --- .../frontend/app/routes/editor/xml-utils.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/frontend/app/routes/editor/xml-utils.ts b/src/main/frontend/app/routes/editor/xml-utils.ts index 28314838..f26b074e 100644 --- a/src/main/frontend/app/routes/editor/xml-utils.ts +++ b/src/main/frontend/app/routes/editor/xml-utils.ts @@ -11,7 +11,20 @@ export interface FrankElementLocation { adapterPosition: number } -const STRUCTURAL_TAGS = new Set(['Adapter', 'Configuration', 'Module', 'Pipeline', 'Exits']) +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 @@ -20,6 +33,7 @@ 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(':') @@ -97,7 +111,7 @@ function hasNameAttributeWithinTag(lines: string[], startLine: number, targetNam return true } - if (i > startLine && /^\s*<[A-Za-z]/.test(lines[i])) { + if (i > startLine && REGEX_NEW_TAG_START.test(lines[i])) { return false } } From b118f33fee1ecbd0c924c0332455c8414de7fd0c Mon Sep 17 00:00:00 2001 From: Stijn Potters Date: Wed, 13 May 2026 14:10:32 +0200 Subject: [PATCH 07/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Stijn Potters --- src/main/frontend/app/routes/editor/editor.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 5c8cb795..48bab17b 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -841,7 +841,6 @@ export default function CodeEditor() { if (value && fileLanguage === 'xml') { scheduleSchemaValidation(value) applyFlowHighlighter() - applyFrankGlyphs(value) } }} options={{ From 7a526ddef3e92f036d14ea825717ed8d71b84b7d Mon Sep 17 00:00:00 2001 From: Stijn Potters Date: Wed, 13 May 2026 14:11:06 +0200 Subject: [PATCH 08/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Stijn Potters --- src/main/frontend/app/actions/navigationActions.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/frontend/app/actions/navigationActions.ts b/src/main/frontend/app/actions/navigationActions.ts index ed1c5c0e..28fac4c6 100644 --- a/src/main/frontend/app/actions/navigationActions.ts +++ b/src/main/frontend/app/actions/navigationActions.ts @@ -35,18 +35,21 @@ export function openInEditor(relativePath: string, filepath: string) { } export function openInEditorAtElement(subtype: string, name: string | undefined, filepath: string) { - const { setTabData, setActiveTab, getTab, setPendingHighlight } = useEditorTabStore.getState() + const { setTabData, setActiveTab, getTab } = useEditorTabStore.getState() const fileName = filepath.split(/[/\\]/).pop() ?? filepath + const existing = getTab(filepath) - if (!getTab(filepath)) { + if (existing) { + setTabData(filepath, { ...existing, pendingHighlight: { subtype, name } }) + } else { setTabData(filepath, { name: fileName, configurationPath: filepath, + pendingHighlight: { subtype, name }, }) } - setPendingHighlight({ subtype, name }) setActiveTab(filepath) useNavigationStore.getState().navigate('editor') } From 3aa3144ec9739f7ee4668ef7569ff2c8a33294ab Mon Sep 17 00:00:00 2001 From: Stijn Potters Date: Wed, 13 May 2026 14:16:27 +0200 Subject: [PATCH 09/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Stijn Potters --- .../frontend/app/routes/editor/xml-utils.ts | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/main/frontend/app/routes/editor/xml-utils.ts b/src/main/frontend/app/routes/editor/xml-utils.ts index f26b074e..e1be60f6 100644 --- a/src/main/frontend/app/routes/editor/xml-utils.ts +++ b/src/main/frontend/app/routes/editor/xml-utils.ts @@ -243,28 +243,36 @@ export function findFrankElementsForGlyphs(xml: string): FrankElementLocation[] const lines = xml.split('\n') const results: FrankElementLocation[] = [] const tagStack: string[] = [] + const tagTokenRegex = /<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex] + const tokens = line.matchAll(tagTokenRegex) - processClosingTags(line, tagStack) + for (const token of tokens) { + const fullTag = token[0] + const rawTag = token[1] + const baseTagName = getLocalName(rawTag) - const openMatch = line.match(REGEX_OPEN_TAG) - if (!openMatch) continue + if (fullTag.startsWith('') + if (!isSelfClosing) { + tagStack.push(baseTagName) + } } } From 5f84a20a5a6a32bf1c52c044196fd1de7146d7d0 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 13 May 2026 14:56:36 +0200 Subject: [PATCH 10/11] Refactor editor and flow logic for improved node selection and highlight handling --- .../frontend/app/actions/navigationActions.ts | 14 +-- .../frontend/app/routes/editor/editor.tsx | 2 +- .../frontend/app/routes/editor/xml-utils.ts | 24 ++-- .../app/routes/studio/canvas/flow.tsx | 110 +++++++----------- .../studio/canvas/nodetypes/frank-node.tsx | 4 +- .../app/routes/studio/xml-to-json-parser.ts | 2 +- 6 files changed, 62 insertions(+), 94 deletions(-) diff --git a/src/main/frontend/app/actions/navigationActions.ts b/src/main/frontend/app/actions/navigationActions.ts index 28fac4c6..ef67b250 100644 --- a/src/main/frontend/app/actions/navigationActions.ts +++ b/src/main/frontend/app/actions/navigationActions.ts @@ -35,22 +35,18 @@ export function openInEditor(relativePath: string, filepath: string) { } export function openInEditorAtElement(subtype: string, name: string | undefined, filepath: string) { - const { setTabData, setActiveTab, getTab } = useEditorTabStore.getState() - + const editorStore = useEditorTabStore.getState() const fileName = filepath.split(/[/\\]/).pop() ?? filepath - const existing = getTab(filepath) - if (existing) { - setTabData(filepath, { ...existing, pendingHighlight: { subtype, name } }) - } else { - setTabData(filepath, { + if (!editorStore.getTab(filepath)) { + editorStore.setTabData(filepath, { name: fileName, configurationPath: filepath, - pendingHighlight: { subtype, name }, }) } - setActiveTab(filepath) + editorStore.setPendingHighlight({ subtype, name }) + editorStore.setActiveTab(filepath) useNavigationStore.getState().navigate('editor') } diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index 48bab17b..6432e375 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -737,7 +737,7 @@ export default function CodeEditor() { options: { isWholeLine: true, className: 'highlight-line' }, }, ]) - }, [pendingHighlight, fileContent, isDiffTab]) + }, [pendingHighlight, fileContent, isDiffTab, editorMounted]) const handleOpenInStudio = useCallback(() => { const editorTab = useEditorTabStore.getState().getTab(activeTabFilePath) diff --git a/src/main/frontend/app/routes/editor/xml-utils.ts b/src/main/frontend/app/routes/editor/xml-utils.ts index e1be60f6..6ce99305 100644 --- a/src/main/frontend/app/routes/editor/xml-utils.ts +++ b/src/main/frontend/app/routes/editor/xml-utils.ts @@ -122,6 +122,8 @@ export function findAdaptersInXml(xml: string): AdapterLocation[] { const adapters: AdapterLocation[] = [] let match: RegExpExecArray | null + REGEX_ADAPTER.lastIndex = 0 + while ((match = REGEX_ADAPTER.exec(xml)) !== null) { adapters.push({ name: match[2], offset: match.index }) } @@ -155,7 +157,7 @@ export function extractFlowElements(xml: string): string | null { export function wrapFlowXml(fragment: string): string { const innerContent = fragment .replace(/^]*>/, '') - .replace(/<\/flow:FlowElements>$/, '') + .replace('', '') .trim() return `${innerContent}` @@ -243,24 +245,14 @@ export function findFrankElementsForGlyphs(xml: string): FrankElementLocation[] const lines = xml.split('\n') const results: FrankElementLocation[] = [] const tagStack: string[] = [] - const tagTokenRegex = /<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex] - const tokens = line.matchAll(tagTokenRegex) - for (const token of tokens) { - const fullTag = token[0] - const rawTag = token[1] + const openMatch = line.match(REGEX_OPEN_TAG) + if (openMatch) { + const rawTag = openMatch[1] const baseTagName = getLocalName(rawTag) - - if (fullTag.startsWith('') + 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 efd0c035..56343cee 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -89,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)) @@ -177,24 +179,34 @@ function FlowCanvas() { const applySelectionToNodes = useCallback((pendingSelection: { subtype: string; name: string }) => { const currentNodes = useFlowStore.getState().nodes const nodeToSelect = currentNodes.find( - (n) => - n.type === 'frankNode' && - isFrankNode(n) && - n.data.subtype === pendingSelection.subtype && - n.data.name === pendingSelection.name, - ) as FrankNodeType | undefined + (node): node is FrankNodeType => + isFrankNode(node) && node.data.subtype === pendingSelection.subtype && node.data.name === pendingSelection.name, + ) + if (!nodeToSelect) return - useFlowStore.getState().setNodes(currentNodes.map((n) => ({ ...n, selected: n.id === nodeToSelect.id }))) - reactFlowRef.current?.fitView({ nodes: [{ id: nodeToSelect.id }], padding: 0.5, duration: 0 }) + useFlowStore.getState().setNodes( + currentNodes.map((node) => ({ + ...node, + selected: node.id === nodeToSelect.id, + })), + ) - const ncs = useNodeContextStore.getState() - ncs.setParentId(null) - ncs.setChildParentId(null) - ncs.setNodeId(+nodeToSelect.id) - ncs.setAttributes(elementsRef.current?.[nodeToSelect.data.subtype]?.attributes) - ncs.setEditingSubtype(nodeToSelect.data.subtype) - ncs.setIsEditing(true) + 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) }, []) @@ -634,8 +646,6 @@ function FlowCanvas() { '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 @@ -1034,10 +1044,6 @@ function FlowCanvas() { setLoading(true) const pendingSelection = tab.pendingNodeSelection ?? null - if (pendingSelection) { - const tabStore = useTabStore.getState() - tabStore.setTabData(tabStore.activeTab, { ...tab, pendingNodeSelection: null }) - } try { if (tab.flowJson && Object.keys(tab.flowJson).length > 0) { @@ -1052,31 +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) + setLoading(false) } finally { - setTimeout(() => { - isLoadingTabRef.current = false - }, 0) - if (!pendingSelection) { - setLoading(false) - } - } - - if (pendingSelection) { - requestAnimationFrame(() => - requestAnimationFrame(() => { - applySelectionToNodes(pendingSelection) - setLoading(false) - }), - ) + isLoadingTabRef.current = false } } @@ -1131,33 +1138,6 @@ function FlowCanvas() { return () => unsubscribe() }, [layoutGraph]) - useEffect(() => { - function consumePendingSelection() { - if (isLoadingTabRef.current) return - - const tabStore = useTabStore.getState() - const tabId = tabStore.activeTab - const tab = tabStore.getTab(tabId) - const pending = tab?.pendingNodeSelection ?? null - if (!pending) return - - tabStore.setTabData(tabId, { ...tab!, pendingNodeSelection: null }) - requestAnimationFrame(() => applySelectionToNodes(pending)) - } - - consumePendingSelection() - - const unsubscribePending = useTabStore.subscribe( - (state) => state.tabs[state.activeTab]?.pendingNodeSelection ?? null, - (pendingSelection) => { - if (!pendingSelection) return - consumePendingSelection() - }, - ) - return () => unsubscribePending() - }, [applySelectionToNodes]) - - useEffect(() => { const unsub = useFlowStore.subscribe( (state) => state.nodes, 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/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: { From b4e5ba7eebd0fb357c33fb6c92966bf4c14760ea Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 18 May 2026 16:45:42 +0200 Subject: [PATCH 11/11] Add pending selection handling to improve node selection process --- .../app/routes/studio/canvas/flow.tsx | 25 ++++++++++++++----- .../routes/studio/context/node-context.tsx | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 56343cee..46617683 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -170,6 +170,7 @@ function FlowCanvas() { const autoSaveTimerRef = useRef | null>(null) const savedTimerRef = useRef | null>(null) const isLoadingTabRef = useRef(false) + const pendingSelectionRef = useRef<{ subtype: string; name: string } | null>(null) const updateNodeInternals = useUpdateNodeInternals() const reactFlow = useReactFlow() @@ -307,6 +308,23 @@ function FlowCanvas() { } }, [nodes, edges, scheduleAutoSave]) + useEffect(() => { + const pending = pendingSelectionRef.current + + if (!pending) return + + const targetNode = nodes.find( + (node): node is FrankNodeType => + isFrankNode(node) && node.data.subtype === pending.subtype && node.data.name === pending.name, + ) + + if (!targetNode) return + + pendingSelectionRef.current = null + applySelectionToNodes(pending) + setLoading(false) + }, [nodes, applySelectionToNodes]) + useEffect(() => { useNodeContextStore.getState().registerSaveFlow(async () => { if (autoSaveTimerRef.current) { @@ -1070,12 +1088,7 @@ function FlowCanvas() { const tabStore = useTabStore.getState() tabStore.setTabData(tabStore.activeTab, { ...tab, pendingNodeSelection: null }) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - applySelectionToNodes(pendingSelection) - setLoading(false) - }) - }) + pendingSelectionRef.current = pendingSelection } else { setLoading(false) } 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 53360d7e..68e62987 100644 --- a/src/main/frontend/app/routes/studio/context/node-context.tsx +++ b/src/main/frontend/app/routes/studio/context/node-context.tsx @@ -386,7 +386,7 @@ export default function NodeContext({