From b7309b6933dd1b4a74ddc0e5bf6f92a3c85873b7 Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Wed, 4 Mar 2026 14:07:39 +1300 Subject: [PATCH 1/6] Fix debugger polling to fetch only visible variables --- src/renderer/screens/workspace-screen.tsx | 315 +++++----------------- 1 file changed, 69 insertions(+), 246 deletions(-) diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index e4585790c..b6d9fe9d2 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -1172,7 +1172,7 @@ const WorkspaceScreen = () => { } }) - const { editor, ladderFlows } = useOpenPLCStore.getState() + const { editor, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const currentPou = currentProject.data.pous.find((pou) => pou.data.name === editor.meta.name) // Helper to create composite key for current POU, handling FB instance context @@ -1212,127 +1212,66 @@ const WorkspaceScreen = () => { } } } - }) - }) - } - - // Get FB instance context for function block POUs - let fbInstanceCtx: { programName: string; fbVariableName: string } | null = null - if (currentPou.type === 'function-block') { - const fbTypeKey = currentPou.data.name.toUpperCase() - const selectedKey = fbSelectedInstance.get(fbTypeKey) - if (selectedKey) { - const instances = fbDebugInstances.get(fbTypeKey) || [] - const selectedInstance = instances.find((inst) => inst.key === selectedKey) - if (selectedInstance) { - fbInstanceCtx = { - programName: selectedInstance.programName, - fbVariableName: selectedInstance.fbVariableName, - } - } - } - } - - // For FB POUs, poll nested FB variables using instance context - // For program POUs, poll FB instance variables using the standard approach - if (currentPou.type === 'function-block' && fbInstanceCtx) { - // Poll all nested BOOL variables within the FB instance - Array.from(variableInfoMapRef.current.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === fbInstanceCtx.programName && - varInfo.variable.name.startsWith(`${fbInstanceCtx.fbVariableName}.`) && - varInfo.variable.type.definition === 'base-type' && - varInfo.variable.type.value.toLowerCase() === 'bool' - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` - debugVariableKeys.add(compositeKey) - } - } - }) - } else { - const functionBlockInstances = currentPou.data.variables.filter( - (variable) => variable.type.definition === 'derived', - ) - functionBlockInstances.forEach((fbInstance) => { - Array.from(variableInfoMapRef.current!.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === currentPou.data.name && - varInfo.variable.name.startsWith(`${fbInstance.name}.`) && - varInfo.variable.type.definition === 'base-type' && - varInfo.variable.type.value.toLowerCase() === 'bool' - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` - debugVariableKeys.add(compositeKey) + // Variable nodes can display non-BOOL DebugValueBadges and also support force menus. + // Poll only the variable referenced by the node (not every variable in the POU). + if (node.type === 'variable') { + const nodeData = node.data as { + variable?: { name?: string } + } + const variableName = nodeData.variable?.name + if (variableName) { + const compositeKey = makeCompositeKeyForCurrentPou(variableName) + if (compositeKey) { + debugVariableKeys.add(compositeKey) + } } } - }) - }) - } - // For FB POUs, poll function outputs using instance context - // For program POUs, poll function outputs using the standard approach - if (currentPou.type === 'function-block' && fbInstanceCtx && currentLadderFlow) { - currentLadderFlow.rungs.forEach((rung) => { - rung.nodes.forEach((node) => { + // Block nodes may show output DebugValueBadges (function blocks + functions). + // Poll only those output variables for blocks present on the diagram. if (node.type === 'block') { const blockData = node.data as { - variant?: { type: string } + variant?: { + type?: string + name?: string + variables?: Array<{ name: string; class: string; type: { definition: string; value: string } }> + } + variable?: { name?: string } numericId?: string } - if (blockData.variant?.type === 'function' && blockData.numericId) { - Array.from(variableInfoMapRef.current!.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === fbInstanceCtx.programName && - varInfo.variable.name.startsWith(`${fbInstanceCtx.fbVariableName}.`) && - varInfo.variable.name.includes(blockData.numericId!) - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` + const variantType = blockData.variant?.type + const variantName = blockData.variant?.name + const variantVars = blockData.variant?.variables ?? [] + const outputVars = variantVars.filter((v) => v.class === 'output' || v.class === 'inOut') + + if (variantType === 'function-block') { + const instanceName = blockData.variable?.name + if (instanceName) { + for (const outVar of outputVars) { + const compositeKey = makeCompositeKeyForCurrentPou(`${instanceName}.${outVar.name}`) + if (compositeKey) { debugVariableKeys.add(compositeKey) } } - }) - } - } - }) - }) - } else { - const instances = currentProject.data.configuration.resource.instances - const programInstance = instances.find((inst) => inst.program === currentPou.data.name) - if (programInstance && currentLadderFlow) { - currentLadderFlow.rungs.forEach((rung) => { - rung.nodes.forEach((node) => { - if (node.type === 'block') { - const blockData = node.data as { - variant?: { type: string } - numericId?: string } - - if (blockData.variant?.type === 'function' && blockData.numericId) { - Array.from(variableInfoMapRef.current!.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === currentPou.data.name && - varInfo.variable.name.includes(blockData.numericId!) - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` - debugVariableKeys.add(compositeKey) - } - } - }) + } else if (variantType === 'function' && variantName && blockData.numericId) { + const numericId = blockData.numericId + for (const outVar of outputVars) { + const tmpName = `_TMP_${variantName.toUpperCase()}${numericId}_${outVar.name}` + const compositeKey = makeCompositeKeyForCurrentPou(tmpName) + if (compositeKey) { + debugVariableKeys.add(compositeKey) + } } } - }) + } }) - } + }) } } - const { fbdFlows } = useOpenPLCStore.getState() if (currentPou && currentPou.data.body.language === 'fbd') { const currentFbdFlow = fbdFlows.find((flow) => flow.name === editor.meta.name) if (currentFbdFlow) { @@ -1344,164 +1283,48 @@ const WorkspaceScreen = () => { const variableName = nodeData.variable?.name if (variableName) { - const variable = currentPou.data.variables.find( - (v) => v.name.toLowerCase() === variableName.toLowerCase(), - ) - if (variable && variable.type.value.toUpperCase() === 'BOOL') { - const compositeKey = makeCompositeKeyForCurrentPou(variableName) - if (compositeKey) { - debugVariableKeys.add(compositeKey) - } - } - } - } - }) - } - - // Get FB instance context for function block POUs (FBD) - let fbdFbInstanceCtx: { programName: string; fbVariableName: string } | null = null - if (currentPou.type === 'function-block') { - const fbTypeKey = currentPou.data.name.toUpperCase() - const selectedKey = fbSelectedInstance.get(fbTypeKey) - if (selectedKey) { - const instances = fbDebugInstances.get(fbTypeKey) || [] - const selectedInstance = instances.find((inst) => inst.key === selectedKey) - if (selectedInstance) { - fbdFbInstanceCtx = { - programName: selectedInstance.programName, - fbVariableName: selectedInstance.fbVariableName, - } - } - } - } - - // For FB POUs, poll nested FB variables using instance context - // For program POUs, poll FB instance variables using the standard approach - if (currentPou.type === 'function-block' && fbdFbInstanceCtx) { - // Poll all nested BOOL variables within the FB instance - Array.from(variableInfoMapRef.current.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === fbdFbInstanceCtx.programName && - varInfo.variable.name.startsWith(`${fbdFbInstanceCtx.fbVariableName}.`) && - varInfo.variable.type.definition === 'base-type' && - varInfo.variable.type.value.toLowerCase() === 'bool' - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` - debugVariableKeys.add(compositeKey) - } - } - }) - } else { - const functionBlockInstances = currentPou.data.variables.filter( - (variable) => variable.type.definition === 'derived', - ) - - functionBlockInstances.forEach((fbInstance) => { - Array.from(variableInfoMapRef.current!.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === currentPou.data.name && - varInfo.variable.name.startsWith(`${fbInstance.name}.`) && - varInfo.variable.type.definition === 'base-type' && - varInfo.variable.type.value.toLowerCase() === 'bool' - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` + const compositeKey = makeCompositeKeyForCurrentPou(variableName) + if (compositeKey) { debugVariableKeys.add(compositeKey) } } - }) - }) - } + } - // For FB POUs, poll function outputs using instance context - // For program POUs, poll function outputs using the standard approach - if (currentPou.type === 'function-block' && fbdFbInstanceCtx && currentFbdFlow) { - currentFbdFlow.rung.nodes.forEach((node) => { if (node.type === 'block') { const blockData = node.data as { - variant?: { type: string } + variant?: { + type?: string + name?: string + variables?: Array<{ name: string; class: string; type: { definition: string; value: string } }> + } + variable?: { name?: string } numericId?: string } - if (blockData.variant?.type === 'function' && blockData.numericId) { - Array.from(variableInfoMapRef.current!.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === fbdFbInstanceCtx.programName && - varInfo.variable.name.startsWith(`${fbdFbInstanceCtx.fbVariableName}.`) && - varInfo.variable.name.includes(blockData.numericId!) - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` + const variantType = blockData.variant?.type + const variantName = blockData.variant?.name + const variantVars = blockData.variant?.variables ?? [] + const outputVars = variantVars.filter((v) => v.class === 'output' || v.class === 'inOut') + + if (variantType === 'function-block') { + const instanceName = blockData.variable?.name + if (instanceName) { + for (const outVar of outputVars) { + const compositeKey = makeCompositeKeyForCurrentPou(`${instanceName}.${outVar.name}`) + if (compositeKey) { debugVariableKeys.add(compositeKey) } } - }) - } - } - }) - } else { - const instances = currentProject.data.configuration.resource.instances - const programInstance = instances.find((inst) => inst.program === currentPou.data.name) - if (programInstance && currentFbdFlow) { - currentFbdFlow.rung.nodes.forEach((node) => { - if (node.type === 'block') { - const blockData = node.data as { - variant?: { type: string } - numericId?: string - } - - if (blockData.variant?.type === 'function' && blockData.numericId) { - Array.from(variableInfoMapRef.current!.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === currentPou.data.name && - varInfo.variable.name.includes(blockData.numericId!) - ) { - const compositeKey = `${varInfo.pouName}:${varInfo.variable.name}` - debugVariableKeys.add(compositeKey) - } - } - }) } - } - }) - } - } - } - - // Poll all variables of the active POU so non-BOOL values can be displayed on the diagram. - // This adds every variable registered in variableInfoMapRef that belongs to the current POU, - // enabling the DebugValueBadge components to show real-time values for INT, REAL, etc. - if (currentPou) { - if (currentPou.type === 'function-block') { - // For FB POUs, resolve the selected instance context and match variables by - // program name + instance path prefix (same pattern used for BOOL polling above). - const fbTypeKey = currentPou.data.name.toUpperCase() - const selectedKey = fbSelectedInstance.get(fbTypeKey) - if (selectedKey) { - const instances = fbDebugInstances.get(fbTypeKey) || [] - const selectedInstance = instances.find((inst) => inst.key === selectedKey) - if (selectedInstance) { - const instancePrefix = `${selectedInstance.fbVariableName}.` - Array.from(variableInfoMapRef.current.values()).forEach((varInfos) => { - for (const varInfo of varInfos) { - if ( - varInfo.pouName === selectedInstance.programName && - varInfo.variable.name.startsWith(instancePrefix) - ) { - debugVariableKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) + } else if (variantType === 'function' && variantName && blockData.numericId) { + const numericId = blockData.numericId + for (const outVar of outputVars) { + const tmpName = `_TMP_${variantName.toUpperCase()}${numericId}_${outVar.name}` + const compositeKey = makeCompositeKeyForCurrentPou(tmpName) + if (compositeKey) { + debugVariableKeys.add(compositeKey) } } - }) - } - } - } else { - const activePouName = currentPou.data.name - Array.from(variableInfoMapRef.current.values()).forEach((varInfos) => { - for (const varInfo of varInfos) { - if (varInfo.pouName === activePouName) { - debugVariableKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) } } }) From 975b793744a959e1691b2b0ef587b646b2c9f9cf Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Wed, 4 Mar 2026 16:08:28 +1300 Subject: [PATCH 2/6] fix(debugger): always poll watched nested/array keys --- src/renderer/screens/workspace-screen.tsx | 73 +++++++++++++++-------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index b6d9fe9d2..8bb1d4247 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -1115,44 +1115,71 @@ const WorkspaceScreen = () => { // creates a watched key like main:IRRIGATION_MAIN_CONTROLLER0.TON0, and its children // (like ET, PT) should be polled when TON0 is expanded const shouldPollNestedVariable = (varName: string, pouName: string, currentGraphList: string[]): boolean => { - const parts = varName.split('.') - if (parts.length <= 1) return true // Not a nested variable - - // Check if this variable is in the graph list + // Fast-path: graphed variables must always be polled. const compositeKey = `${pouName}:${varName}` if (currentGraphList.includes(compositeKey)) { return true } - // Find the deepest watched ancestor - // For example, if varName is 'IRRIGATION_MAIN_CONTROLLER0.TON0.ET': - // - Check if 'main:IRRIGATION_MAIN_CONTROLLER0.TON0' is watched (deepest) - // - If not, check if 'main:IRRIGATION_MAIN_CONTROLLER0' is watched - let watchedAncestorIndex = -1 - for (let i = parts.length - 1; i >= 1; i--) { - const candidatePath = parts.slice(0, i).join('.') - const candidateKey = `${pouName}:${candidatePath}` + if (debugVariableKeys.has(compositeKey)) { + return true + } + + const hierarchyPaths: string[] = [] + const dotParts = varName.split('.') + let prefix = '' + for (const part of dotParts) { + const hasBracket = part.includes('[') + if (hasBracket) { + const base = part.split('[')[0] + if (base) { + const basePath = prefix ? `${prefix}.${base}` : base + if (hierarchyPaths[hierarchyPaths.length - 1] !== basePath) { + hierarchyPaths.push(basePath) + } + } + } + + const fullPath = prefix ? `${prefix}.${part}` : part + hierarchyPaths.push(fullPath) + prefix = fullPath + } + + // If there's no hierarchy, treat as not pollable. + if (hierarchyPaths.length === 0) { + return false + } + + // If this is not nested (single path, no bracket-derived parent), it must be explicitly watched/forced. + // (This matches previous behavior for simple variables.) + if (hierarchyPaths.length === 1) { + return debugVariableKeys.has(`${pouName}:${hierarchyPaths[0]}`) + } + + // Find the deepest watched ancestor in the hierarchy. + let watchedAncestorPos = -1 + for (let i = hierarchyPaths.length - 2; i >= 0; i--) { + const candidateKey = `${pouName}:${hierarchyPaths[i]}` if (debugVariableKeys.has(candidateKey)) { - watchedAncestorIndex = i + watchedAncestorPos = i break } } - // If no ancestor is watched, don't poll this variable - if (watchedAncestorIndex === -1) { + if (watchedAncestorPos === -1) { return false } - // Check if all nodes from the watched ancestor to this variable are expanded - // Start from the watched ancestor (which must be expanded to see its children) - for (let i = watchedAncestorIndex; i < parts.length; i++) { - const parentPath = parts.slice(0, i).join('.') - const parentKey = `${pouName}:${parentPath}` + // Ensure every ancestor from watched -> parent of target is expanded. + // The target node itself does not need to be expanded to show its value. + for (let i = watchedAncestorPos; i < hierarchyPaths.length - 1; i++) { + const parentKey = `${pouName}:${hierarchyPaths[i]}` const isParentExpanded = debugExpandedNodes.get(parentKey) ?? false if (!isParentExpanded) { return false } } + return true } @@ -1160,11 +1187,9 @@ const WorkspaceScreen = () => { // This now supports arbitrary nesting depth by finding the deepest watched ancestor Array.from(variableInfoMapRef.current.entries()).forEach(([_, varInfos]) => { for (const varInfo of varInfos) { - if (varInfo.variable.name.includes('.')) { + // Treat both dot-nesting (A.B) and array indexing (A[1]) as nested. + if (varInfo.variable.name.includes('.') || varInfo.variable.name.includes('[')) { const childKey = `${varInfo.pouName}:${varInfo.variable.name}` - - // Check if this nested variable should be polled based on expansion state - // shouldPollNestedVariable now handles finding the watched ancestor internally if (shouldPollNestedVariable(varInfo.variable.name, varInfo.pouName, graphListRef.current)) { debugVariableKeys.add(childKey) } From 22c52fa7e9bab0322a3b8d17d07eec54df2ace0d Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 12 Mar 2026 18:05:30 -0400 Subject: [PATCH 3/6] feat: add ST/IL variable polling for inline debug badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an ST/IL section to the debugger polling tick that scans the POU source text for variable references and adds them to debugVariableKeys. This follows the same pattern as the LD/FBD sections — only variables that actually appear in the code are polled, not every declared variable. Also polls FB instance sub-variables (e.g., TON0.ET, TON0.Q) when the instance name appears in the source text. Co-Authored-By: Claude Opus 4.6 --- src/renderer/screens/workspace-screen.tsx | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index 8bb1d4247..bfafdf624 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -1356,6 +1356,64 @@ const WorkspaceScreen = () => { } } + // --- ST/IL: poll variables that appear in the editor source text --- + if (currentPou && (currentPou.data.body.language === 'st' || currentPou.data.body.language === 'il')) { + const sourceText = typeof currentPou.data.body.value === 'string' ? currentPou.data.body.value : '' + if (sourceText) { + // Build candidate list: all POU variables + const candidates = currentPou.data.variables.map((v) => v.name).filter((n) => n && n.trim() !== '') + + // Check which variables appear in the source text (word-boundary match) + for (const varName of candidates) { + const pattern = new RegExp(`\\b${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') + if (pattern.test(sourceText)) { + const compositeKey = makeCompositeKeyForCurrentPou(varName) + if (compositeKey) { + debugVariableKeys.add(compositeKey) + } + } + } + + // For derived-type (FB instance) variables that appear in source, poll their children + const fbInstances = currentPou.data.variables.filter( + (v) => v.type.definition === 'derived' && v.name && v.name.trim() !== '', + ) + for (const fbInstance of fbInstances) { + const fbPattern = new RegExp(`\\b${fbInstance.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') + if (!fbPattern.test(sourceText)) continue + + Array.from(variableInfoMapRef.current.entries()).forEach(([_, varInfos]) => { + for (const varInfo of varInfos) { + if (currentPou.type === 'function-block') { + // FB POU: resolve through instance context + const fbTypeKey = currentPou.data.name.toUpperCase() + const selectedKey = fbSelectedInstance.get(fbTypeKey) + if (selectedKey) { + const instances = fbDebugInstances.get(fbTypeKey) || [] + const selectedInstance = instances.find((inst) => inst.key === selectedKey) + if ( + selectedInstance && + varInfo.pouName === selectedInstance.programName && + varInfo.variable.name.startsWith(`${selectedInstance.fbVariableName}.${fbInstance.name}.`) + ) { + debugVariableKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) + } + } + } else { + // Program POU: match directly + if ( + varInfo.pouName === currentPou.data.name && + varInfo.variable.name.startsWith(`${fbInstance.name}.`) + ) { + debugVariableKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) + } + } + } + }) + } + } + } + // Forced variables must also be polled so their current value appears in the debugger panel const { workspace: { debugForcedVariables: currentForcedVars }, From 039d3826d2d8afc27edf8898aa3bc7998025b348 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 12 Mar 2026 19:23:14 -0400 Subject: [PATCH 4/6] fix: reset editorMounted on POU switch to prevent debug badge context mixing When multiple ST/IL function blocks are open in tabs, switching between them causes the debugVarPositions memo to run with the new POU name but the old Monaco model (since @monaco-editor/react reuses the component with keepCurrentModel). This resets editorMounted to false on name change so the memo returns null until handleEditorDidMount fires with the correct model. Co-Authored-By: Claude Opus 4.6 --- .../_features/[workspace]/editor/monaco/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx index a0c678c1d..7f8ec0fd6 100644 --- a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx @@ -283,6 +283,12 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { + setEditorMounted(false) + }, [name]) + // Update readOnly when debugger visibility changes on an already-mounted editor useEffect(() => { editorRef.current?.updateOptions({ readOnly: isDebuggerVisible }) From 681f5196e2caaaf63d2c761556c4b102360249c4 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 12 Mar 2026 21:11:51 -0400 Subject: [PATCH 5/6] fix: use onDidChangeModel + URI guard for debug badge model switching The previous fix (resetting editorMounted on name change) didn't work because @monaco-editor/react's onMount only fires once on initial mount, not on subsequent path/model switches with keepCurrentModel={true}. New approach: - Listen to editor.onDidChangeModel to detect when the library switches models (tab change), then bump a modelVersion counter to trigger the debugVarPositions memo recomputation. - Add a URI guard inside the memo that verifies the current model matches the expected POU path, preventing position scanning against a stale model during the render before the model switch completes. Co-Authored-By: Claude Opus 4.6 --- .../[workspace]/editor/monaco/index.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx index 7f8ec0fd6..c72b38746 100644 --- a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx @@ -115,6 +115,7 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType(null) const focusDisposables = useRef<{ onFocus?: monaco.IDisposable; onBlur?: monaco.IDisposable }>({}) const [editorMounted, setEditorMounted] = useState(false) + const [modelVersion, setModelVersion] = useState(0) const { editor, @@ -283,11 +284,17 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { - setEditorMounted(false) - }, [name]) + const editor = editorRef.current + if (!editor) return + const disposable = editor.onDidChangeModel(() => { + setModelVersion((v) => v + 1) + }) + return () => disposable.dispose() + }, [editorMounted]) // Update readOnly when debugger visibility changes on an already-mounted editor useEffect(() => { @@ -321,6 +328,11 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { From dd2c38a7c2a045f1c59db16b03568b5a7535da74 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 12 Mar 2026 22:11:26 -0400 Subject: [PATCH 6/6] perf: cache diagram/source variable keys across poll ticks The LD/FBD node scanning and ST/IL regex matching produce the same result every 50ms poll tick since the editor is read-only during debug. This caches the computed variable key set in a ref, keyed by POU name, language, and FB instance context. The cache is only invalidated when the user switches POUs or changes FB instance selection. Also hoists the FB instance context resolution (fbSelectedInstance, fbDebugInstances lookups) outside the inner loops in the ST/IL FB children section, so it's resolved once per cache rebuild instead of once per varInfo iteration. Co-Authored-By: Claude Opus 4.6 --- src/renderer/screens/workspace-screen.tsx | 412 ++++++++++++---------- 1 file changed, 230 insertions(+), 182 deletions(-) diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index bfafdf624..abcce7e8c 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -176,6 +176,17 @@ const WorkspaceScreen = () => { const graphListRef = useRef([]) const batchOffsetRef = useRef(0) + // Cache for diagram/source variable keys — recomputed only when the active POU or FB + // instance context changes. During debug the editor is read-only, so the same POU+context + // always yields the same set of keys. This avoids rebuilding regex objects (ST/IL) and + // re-scanning flow nodes (LD/FBD) on every 50ms poll tick. + const diagramVarKeysCache = useRef<{ + pouName: string + language: string + fbContextKey: string + keys: Set + } | null>(null) + useEffect(() => { isMountedRef.current = true return () => { @@ -210,6 +221,7 @@ const WorkspaceScreen = () => { pollingIntervalRef.current = null } variableInfoMapRef.current = null + diagramVarKeysCache.current = null return } @@ -1200,217 +1212,252 @@ const WorkspaceScreen = () => { const { editor, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const currentPou = currentProject.data.pous.find((pou) => pou.data.name === editor.meta.name) - // Helper to create composite key for current POU, handling FB instance context - const makeCompositeKeyForCurrentPou = (variableName: string): string | null => { - if (!currentPou) return null - if (currentPou.type === 'function-block') { - const fbTypeKey = currentPou.data.name.toUpperCase() - const selectedKey = fbSelectedInstance.get(fbTypeKey) - if (!selectedKey) return null - const instances = fbDebugInstances.get(fbTypeKey) || [] - const selectedInstance = instances.find((inst) => inst.key === selectedKey) - if (!selectedInstance) return null - return `${selectedInstance.programName}:${selectedInstance.fbVariableName}.${variableName}` - } - return `${currentPou.data.name}:${variableName}` - } + // --- Diagram/source variable keys (cached) --- + // During debug the editor is read-only, so the set of variables visible on the diagram + // or referenced in source text is stable for a given POU + FB instance context. + // We cache this set and only recompute when the active POU or FB context changes, + // avoiding redundant regex compilation (ST/IL) and node scanning (LD/FBD) every 50ms tick. + if (currentPou) { + const pouName = currentPou.data.name + const pouLanguage = currentPou.data.body.language + const fbContextKey = + currentPou.type === 'function-block' ? fbSelectedInstance.get(currentPou.data.name.toUpperCase()) ?? '' : '' + + const cached = diagramVarKeysCache.current + if ( + cached && + cached.pouName === pouName && + cached.language === pouLanguage && + cached.fbContextKey === fbContextKey + ) { + // Reuse cached keys + cached.keys.forEach((key) => debugVariableKeys.add(key)) + } else { + // Recompute: build the set of keys from diagram nodes or source text + const newKeys = new Set() + + // Resolve FB instance context once (used by makeCompositeKey and ST/IL FB children) + let resolvedFbInstance: { programName: string; fbVariableName: string } | null = null + if (currentPou.type === 'function-block') { + const fbTypeKey = currentPou.data.name.toUpperCase() + const selectedKey = fbSelectedInstance.get(fbTypeKey) + if (selectedKey) { + const instances = fbDebugInstances.get(fbTypeKey) || [] + resolvedFbInstance = instances.find((inst) => inst.key === selectedKey) ?? null + } + } - if (currentPou && currentPou.data.body.language === 'ld') { - const currentLadderFlow = ladderFlows.find((flow) => flow.name === editor.meta.name) - if (currentLadderFlow) { - currentLadderFlow.rungs.forEach((rung) => { - rung.nodes.forEach((node) => { - if (node.type === 'contact' || node.type === 'coil') { - const nodeData = node.data as { - variable?: { name?: string; type?: { definition?: string; value?: string } } - } - const variableName = nodeData.variable?.name + const makeCompositeKey = (variableName: string): string | null => { + if (currentPou.type === 'function-block') { + if (!resolvedFbInstance) return null + return `${resolvedFbInstance.programName}:${resolvedFbInstance.fbVariableName}.${variableName}` + } + return `${currentPou.data.name}:${variableName}` + } - if ( - variableName && - nodeData.variable?.type?.definition === 'base-type' && - nodeData.variable?.type?.value?.toUpperCase() === 'BOOL' - ) { - const compositeKey = makeCompositeKeyForCurrentPou(variableName) - if (compositeKey) { - debugVariableKeys.add(compositeKey) - } - } - } + if (pouLanguage === 'ld') { + const currentLadderFlow = ladderFlows.find((flow) => flow.name === editor.meta.name) + if (currentLadderFlow) { + currentLadderFlow.rungs.forEach((rung) => { + rung.nodes.forEach((node) => { + if (node.type === 'contact' || node.type === 'coil') { + const nodeData = node.data as { + variable?: { name?: string; type?: { definition?: string; value?: string } } + } + const variableName = nodeData.variable?.name - // Variable nodes can display non-BOOL DebugValueBadges and also support force menus. - // Poll only the variable referenced by the node (not every variable in the POU). - if (node.type === 'variable') { - const nodeData = node.data as { - variable?: { name?: string } - } - const variableName = nodeData.variable?.name - if (variableName) { - const compositeKey = makeCompositeKeyForCurrentPou(variableName) - if (compositeKey) { - debugVariableKeys.add(compositeKey) + if ( + variableName && + nodeData.variable?.type?.definition === 'base-type' && + nodeData.variable?.type?.value?.toUpperCase() === 'BOOL' + ) { + const compositeKey = makeCompositeKey(variableName) + if (compositeKey) { + newKeys.add(compositeKey) + } + } } - } - } - // Block nodes may show output DebugValueBadges (function blocks + functions). - // Poll only those output variables for blocks present on the diagram. - if (node.type === 'block') { - const blockData = node.data as { - variant?: { - type?: string - name?: string - variables?: Array<{ name: string; class: string; type: { definition: string; value: string } }> + if (node.type === 'variable') { + const nodeData = node.data as { + variable?: { name?: string } + } + const variableName = nodeData.variable?.name + if (variableName) { + const compositeKey = makeCompositeKey(variableName) + if (compositeKey) { + newKeys.add(compositeKey) + } + } } - variable?: { name?: string } - numericId?: string - } - const variantType = blockData.variant?.type - const variantName = blockData.variant?.name - const variantVars = blockData.variant?.variables ?? [] - const outputVars = variantVars.filter((v) => v.class === 'output' || v.class === 'inOut') + if (node.type === 'block') { + const blockData = node.data as { + variant?: { + type?: string + name?: string + variables?: Array<{ + name: string + class: string + type: { definition: string; value: string } + }> + } + variable?: { name?: string } + numericId?: string + } - if (variantType === 'function-block') { - const instanceName = blockData.variable?.name - if (instanceName) { - for (const outVar of outputVars) { - const compositeKey = makeCompositeKeyForCurrentPou(`${instanceName}.${outVar.name}`) - if (compositeKey) { - debugVariableKeys.add(compositeKey) + const variantType = blockData.variant?.type + const variantName = blockData.variant?.name + const variantVars = blockData.variant?.variables ?? [] + const outputVars = variantVars.filter((v) => v.class === 'output' || v.class === 'inOut') + + if (variantType === 'function-block') { + const instanceName = blockData.variable?.name + if (instanceName) { + for (const outVar of outputVars) { + const compositeKey = makeCompositeKey(`${instanceName}.${outVar.name}`) + if (compositeKey) { + newKeys.add(compositeKey) + } + } + } + } else if (variantType === 'function' && variantName && blockData.numericId) { + const numericId = blockData.numericId + for (const outVar of outputVars) { + const tmpName = `_TMP_${variantName.toUpperCase()}${numericId}_${outVar.name}` + const compositeKey = makeCompositeKey(tmpName) + if (compositeKey) { + newKeys.add(compositeKey) + } } } } - } else if (variantType === 'function' && variantName && blockData.numericId) { - const numericId = blockData.numericId - for (const outVar of outputVars) { - const tmpName = `_TMP_${variantName.toUpperCase()}${numericId}_${outVar.name}` - const compositeKey = makeCompositeKeyForCurrentPou(tmpName) + }) + }) + } + } + + if (pouLanguage === 'fbd') { + const currentFbdFlow = fbdFlows.find((flow) => flow.name === editor.meta.name) + if (currentFbdFlow) { + currentFbdFlow.rung.nodes.forEach((node) => { + if ( + node.type === 'input-variable' || + node.type === 'output-variable' || + node.type === 'inout-variable' + ) { + const nodeData = node.data as { + variable?: { name?: string } + } + const variableName = nodeData.variable?.name + + if (variableName) { + const compositeKey = makeCompositeKey(variableName) if (compositeKey) { - debugVariableKeys.add(compositeKey) + newKeys.add(compositeKey) } } } - } - }) - }) - } - } - if (currentPou && currentPou.data.body.language === 'fbd') { - const currentFbdFlow = fbdFlows.find((flow) => flow.name === editor.meta.name) - if (currentFbdFlow) { - currentFbdFlow.rung.nodes.forEach((node) => { - if (node.type === 'input-variable' || node.type === 'output-variable' || node.type === 'inout-variable') { - const nodeData = node.data as { - variable?: { name?: string } - } - const variableName = nodeData.variable?.name - - if (variableName) { - const compositeKey = makeCompositeKeyForCurrentPou(variableName) - if (compositeKey) { - debugVariableKeys.add(compositeKey) - } - } - } - - if (node.type === 'block') { - const blockData = node.data as { - variant?: { - type?: string - name?: string - variables?: Array<{ name: string; class: string; type: { definition: string; value: string } }> - } - variable?: { name?: string } - numericId?: string - } - - const variantType = blockData.variant?.type - const variantName = blockData.variant?.name - const variantVars = blockData.variant?.variables ?? [] - const outputVars = variantVars.filter((v) => v.class === 'output' || v.class === 'inOut') + if (node.type === 'block') { + const blockData = node.data as { + variant?: { + type?: string + name?: string + variables?: Array<{ + name: string + class: string + type: { definition: string; value: string } + }> + } + variable?: { name?: string } + numericId?: string + } - if (variantType === 'function-block') { - const instanceName = blockData.variable?.name - if (instanceName) { - for (const outVar of outputVars) { - const compositeKey = makeCompositeKeyForCurrentPou(`${instanceName}.${outVar.name}`) - if (compositeKey) { - debugVariableKeys.add(compositeKey) + const variantType = blockData.variant?.type + const variantName = blockData.variant?.name + const variantVars = blockData.variant?.variables ?? [] + const outputVars = variantVars.filter((v) => v.class === 'output' || v.class === 'inOut') + + if (variantType === 'function-block') { + const instanceName = blockData.variable?.name + if (instanceName) { + for (const outVar of outputVars) { + const compositeKey = makeCompositeKey(`${instanceName}.${outVar.name}`) + if (compositeKey) { + newKeys.add(compositeKey) + } + } + } + } else if (variantType === 'function' && variantName && blockData.numericId) { + const numericId = blockData.numericId + for (const outVar of outputVars) { + const tmpName = `_TMP_${variantName.toUpperCase()}${numericId}_${outVar.name}` + const compositeKey = makeCompositeKey(tmpName) + if (compositeKey) { + newKeys.add(compositeKey) + } } } } - } else if (variantType === 'function' && variantName && blockData.numericId) { - const numericId = blockData.numericId - for (const outVar of outputVars) { - const tmpName = `_TMP_${variantName.toUpperCase()}${numericId}_${outVar.name}` - const compositeKey = makeCompositeKeyForCurrentPou(tmpName) + }) + } + } + + // ST/IL: poll variables that appear in the editor source text. + // The regex matches are intentionally broad (no comment stripping) — this is the + // polling side, not the display side. Over-matching just polls a few extra variables; + // the Monaco badge display already strips comments before rendering. + if (pouLanguage === 'st' || pouLanguage === 'il') { + const sourceText = typeof currentPou.data.body.value === 'string' ? currentPou.data.body.value : '' + if (sourceText) { + const candidates = currentPou.data.variables.map((v) => v.name).filter((n) => n && n.trim() !== '') + + for (const varName of candidates) { + const pattern = new RegExp(`\\b${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') + if (pattern.test(sourceText)) { + const compositeKey = makeCompositeKey(varName) if (compositeKey) { - debugVariableKeys.add(compositeKey) + newKeys.add(compositeKey) } } } - } - }) - } - } - - // --- ST/IL: poll variables that appear in the editor source text --- - if (currentPou && (currentPou.data.body.language === 'st' || currentPou.data.body.language === 'il')) { - const sourceText = typeof currentPou.data.body.value === 'string' ? currentPou.data.body.value : '' - if (sourceText) { - // Build candidate list: all POU variables - const candidates = currentPou.data.variables.map((v) => v.name).filter((n) => n && n.trim() !== '') - - // Check which variables appear in the source text (word-boundary match) - for (const varName of candidates) { - const pattern = new RegExp(`\\b${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') - if (pattern.test(sourceText)) { - const compositeKey = makeCompositeKeyForCurrentPou(varName) - if (compositeKey) { - debugVariableKeys.add(compositeKey) - } - } - } - // For derived-type (FB instance) variables that appear in source, poll their children - const fbInstances = currentPou.data.variables.filter( - (v) => v.type.definition === 'derived' && v.name && v.name.trim() !== '', - ) - for (const fbInstance of fbInstances) { - const fbPattern = new RegExp(`\\b${fbInstance.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') - if (!fbPattern.test(sourceText)) continue - - Array.from(variableInfoMapRef.current.entries()).forEach(([_, varInfos]) => { - for (const varInfo of varInfos) { - if (currentPou.type === 'function-block') { - // FB POU: resolve through instance context - const fbTypeKey = currentPou.data.name.toUpperCase() - const selectedKey = fbSelectedInstance.get(fbTypeKey) - if (selectedKey) { - const instances = fbDebugInstances.get(fbTypeKey) || [] - const selectedInstance = instances.find((inst) => inst.key === selectedKey) - if ( - selectedInstance && - varInfo.pouName === selectedInstance.programName && - varInfo.variable.name.startsWith(`${selectedInstance.fbVariableName}.${fbInstance.name}.`) - ) { - debugVariableKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) + // For derived-type (FB instance) variables that appear in source, poll their children + const fbInstances = currentPou.data.variables.filter( + (v) => v.type.definition === 'derived' && v.name && v.name.trim() !== '', + ) + for (const fbInstance of fbInstances) { + const fbPattern = new RegExp(`\\b${fbInstance.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') + if (!fbPattern.test(sourceText)) continue + + Array.from(variableInfoMapRef.current.entries()).forEach(([_, varInfos]) => { + for (const varInfo of varInfos) { + if (resolvedFbInstance) { + // FB POU: resolve through instance context (already hoisted above) + if ( + varInfo.pouName === resolvedFbInstance.programName && + varInfo.variable.name.startsWith(`${resolvedFbInstance.fbVariableName}.${fbInstance.name}.`) + ) { + newKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) + } + } else { + // Program POU: match directly + if ( + varInfo.pouName === currentPou.data.name && + varInfo.variable.name.startsWith(`${fbInstance.name}.`) + ) { + newKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) + } } } - } else { - // Program POU: match directly - if ( - varInfo.pouName === currentPou.data.name && - varInfo.variable.name.startsWith(`${fbInstance.name}.`) - ) { - debugVariableKeys.add(`${varInfo.pouName}:${varInfo.variable.name}`) - } - } + }) } - }) + } } + + diagramVarKeysCache.current = { pouName, language: pouLanguage, fbContextKey, keys: newKeys } + newKeys.forEach((key) => debugVariableKeys.add(key)) } } @@ -1570,6 +1617,7 @@ const WorkspaceScreen = () => { clearInterval(pollingIntervalRef.current) pollingIntervalRef.current = null } + diagramVarKeysCache.current = null void window.bridge.debuggerDisconnect().catch((error: unknown) => { const { consoleActions } = useOpenPLCStore.getState() consoleActions.addLog({