From a47858dd79a2ef46bb117f803ac9810ae19ac3d2 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 27 Feb 2026 23:23:46 -0500 Subject: [PATCH 1/2] feat: add live debug inline values for ST/IL Monaco editor Display real-time debug variable values as green inline badges next to variable occurrences in the Structured Text and Instruction List editors, matching the existing debug visualization in FBD/LD graphical editors. - Strip ST comment regions (//, (* *), /* */) before scanning to avoid decorating variables inside comments - Match complex variable expressions (FB members like TON0.Q, array elements like my_array[3]) by reading keys from debugVariableValues - Sort expressions longest-first with overlap detection to prevent partial matches (e.g. TON0 inside TON0.Q) - Set editor to read-only during active debug sessions - Support function-block instance context for composite key resolution Co-Authored-By: Claude Opus 4.6 --- .../[workspace]/editor/monaco/index.tsx | 143 +++++++++++++++++- src/renderer/styles/globals.css | 15 ++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx index bdf832568..239bd12a2 100644 --- a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx @@ -9,7 +9,7 @@ import { getExtensionFromLanguage, getFolderFromPouType } from '@root/utils/PLC/ import { parseHybridPouFromString, parseTextualPouFromString } from '@root/utils/PLC/pou-text-parser' import type { IpcRendererEvent } from 'electron' import * as monaco from 'monaco-editor' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from '../../../[app]/toast/use-toast' import { @@ -65,6 +65,50 @@ const bridge = window.bridge as unknown as { onFileExternalChange: (handler: (event: IpcRendererEvent, data: { filePath: string }) => void) => () => void } +// Replaces comment regions with spaces so column positions are preserved. +// Tracks block comment state across lines: (*..*), /*..*/, and // line comments. +type BlockCommentState = false | 'paren' | 'slash' +function stripLineComments(line: string, state: BlockCommentState): { stripped: string; state: BlockCommentState } { + const chars = [...line] + let i = 0 + let s = state + + while (i < chars.length) { + if (s) { + const endMarker = s === 'paren' ? ')' : '/' + if (chars[i] === '*' && chars[i + 1] === endMarker) { + chars[i] = ' ' + chars[i + 1] = ' ' + i += 2 + s = false + } else { + chars[i] = ' ' + i++ + } + } else { + if (chars[i] === '/' && chars[i + 1] === '/') { + for (let j = i; j < chars.length; j++) chars[j] = ' ' + break + } + if (chars[i] === '(' && chars[i + 1] === '*') { + chars[i] = ' ' + chars[i + 1] = ' ' + i += 2 + s = 'paren' + } else if (chars[i] === '/' && chars[i + 1] === '*') { + chars[i] = ' ' + chars[i + 1] = ' ' + i += 2 + s = 'slash' + } else { + i++ + } + } + } + + return { stripped: chars.join(''), state: s } +} + const MonacoEditor = (props: monacoEditorProps): ReturnType => { const { language, path, name } = props const editorRef = useRef(null) @@ -78,6 +122,10 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { + editorRef.current?.updateOptions({ readOnly: isDebuggerVisible }) + }, [isDebuggerVisible]) + + // Resolve FB instance context for composite key building + const fbInstanceContext = useMemo(() => { + if (!pou || pou.type !== 'function-block') return null + const fbTypeKey = pou.data.name.toUpperCase() + const selectedKey = fbSelectedInstance.get(fbTypeKey) + if (!selectedKey) return null + const instances = fbDebugInstances.get(fbTypeKey) || [] + return instances.find((inst) => inst.key === selectedKey) || null + }, [pou, fbSelectedInstance, fbDebugInstances]) + + // Debug inline value decorations for ST/IL editors + useEffect(() => { + if (!isDebuggerVisible || !editorRef.current || (language !== 'st' && language !== 'il')) { + return + } + + const editorInstance = editorRef.current + const model = editorInstance.getModel() + if (!model) return + + // Build variable expression → value entries from debugVariableValues keys matching this POU. + // This naturally includes complex expressions like "TON0.Q" and "my_array[3]". + const prefix = fbInstanceContext + ? `${fbInstanceContext.programName}:${fbInstanceContext.fbVariableName}.` + : `${name}:` + + const varEntries: Array<{ expr: string; value: string }> = [] + for (const [key, value] of debugVariableValues) { + if (key.startsWith(prefix)) { + varEntries.push({ expr: key.slice(prefix.length), value }) + } + } + + if (varEntries.length === 0) return + + // Sort longest first so "TON0.Q" is matched before "TON0" on the same line + varEntries.sort((a, b) => b.expr.length - a.expr.length) + + // Build per-expression regex: word boundary at start, negative lookahead at end + // to prevent partial matches (e.g. "TON0" inside "TON0.Q") + const exprPatterns = varEntries.map(({ expr, value }) => { + const escaped = expr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return { pattern: new RegExp(`\\b${escaped}(?![\\w.\\[])`, 'gi'), value } + }) + + const decorations: monaco.editor.IModelDeltaDecoration[] = [] + const lineCount = model.getLineCount() + let blockCommentState: BlockCommentState = false + + for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) { + const lineContent = model.getLineContent(lineNumber) + const result = stripLineComments(lineContent, blockCommentState) + blockCommentState = result.state + const stripped = result.stripped + + // Track claimed column ranges to prevent overlapping decorations + const claimed: Array<[number, number]> = [] + + for (const { pattern, value } of exprPatterns) { + pattern.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(stripped)) !== null) { + const startCol = match.index + 1 + const endCol = startCol + match[0].length + + // Skip if overlapping with an already-claimed range + if (claimed.some(([s, e]) => startCol < e && endCol > s)) continue + + claimed.push([startCol, endCol]) + decorations.push({ + range: new monaco.Range(lineNumber, startCol, lineNumber, endCol), + options: { + after: { + content: ` = ${value} `, + inlineClassName: 'debug-inline-value', + }, + }, + }) + break // Only first occurrence per expression per line + } + } + } + + const collection = editorInstance.createDecorationsCollection(decorations) + return () => collection.clear() + }, [isDebuggerVisible, debugVariableValues, pou?.data.body.value, language, name, fbInstanceContext]) + const variablesSuggestions = useCallback( (range: monaco.IRange) => { const suggestions = tableVariablesCompletion({ @@ -826,6 +966,7 @@ void loop() dropIntoEditor: { enabled: true, }, + readOnly: isDebuggerVisible, } const handleDrop = (ev: React.DragEvent) => { diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index ad5168472..63ca18720 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -184,6 +184,21 @@ z-index: 10 !important; } +/* Debug inline value badges for ST/IL editors during debug sessions */ +.oplc-monaco-wrapper .debug-inline-value { + color: #ffffff; + background-color: #2e7d32; + padding: 0px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + margin-left: 8px; +} + +.dark .oplc-monaco-wrapper .debug-inline-value { + background-color: #388e3c; +} + /* Ensure Radix popper/select content sits well above Monaco */ [data-radix-popper-content-wrapper], [data-radix-select-content], From e4963770fe298363c3815b8e5482f2708943db96 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 27 Feb 2026 23:44:22 -0500 Subject: [PATCH 2/2] perf: cache variable positions in ST/IL debug inline decorations Split the single useEffect into a position-scanning useMemo (runs once when the watched variable set changes) and a value-stamping useEffect (runs on each 50ms poll with O(n) map lookups only). The editor is read-only during debug so positions are stable and don't need rescanning. Co-Authored-By: Claude Opus 4.6 --- .../[workspace]/editor/monaco/index.tsx | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx index 239bd12a2..31226bac3 100644 --- a/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/monaco/index.tsx @@ -297,82 +297,85 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType inst.key === selectedKey) || null }, [pou, fbSelectedInstance, fbDebugInstances]) - // Debug inline value decorations for ST/IL editors - useEffect(() => { - if (!isDebuggerVisible || !editorRef.current || (language !== 'st' && language !== 'il')) { - return - } + // Stable key derived from the set of debug variable names (not values). + // Only changes when a variable is added/removed from the watch list. + const debugVarKeySet = useMemo(() => { + const keys: string[] = [] + for (const key of debugVariableValues.keys()) keys.push(key) + return keys.sort().join('\0') + }, [debugVariableValues]) + + // Phase 1: scan the document for variable positions once. + // Re-runs only when the watched variable set, FB context, or editor identity changes — + // NOT on every 50ms value poll. The editor is read-only during debug so positions are stable. + const debugVarPositions = useMemo(() => { + if (!isDebuggerVisible || !editorRef.current || (language !== 'st' && language !== 'il')) return null + + const model = editorRef.current.getModel() + if (!model) return null - const editorInstance = editorRef.current - const model = editorInstance.getModel() - if (!model) return - - // Build variable expression → value entries from debugVariableValues keys matching this POU. - // This naturally includes complex expressions like "TON0.Q" and "my_array[3]". const prefix = fbInstanceContext ? `${fbInstanceContext.programName}:${fbInstanceContext.fbVariableName}.` : `${name}:` - const varEntries: Array<{ expr: string; value: string }> = [] - for (const [key, value] of debugVariableValues) { - if (key.startsWith(prefix)) { - varEntries.push({ expr: key.slice(prefix.length), value }) - } + // Extract variable names from the key set (values are irrelevant for position scanning) + const varNames: string[] = [] + for (const key of debugVariableValues.keys()) { + if (key.startsWith(prefix)) varNames.push(key.slice(prefix.length)) } - - if (varEntries.length === 0) return + if (varNames.length === 0) return null // Sort longest first so "TON0.Q" is matched before "TON0" on the same line - varEntries.sort((a, b) => b.expr.length - a.expr.length) + varNames.sort((a, b) => b.length - a.length) - // Build per-expression regex: word boundary at start, negative lookahead at end - // to prevent partial matches (e.g. "TON0" inside "TON0.Q") - const exprPatterns = varEntries.map(({ expr, value }) => { + const exprPatterns = varNames.map((expr) => { const escaped = expr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return { pattern: new RegExp(`\\b${escaped}(?![\\w.\\[])`, 'gi'), value } + return { expr, pattern: new RegExp(`\\b${escaped}(?![\\w.\\[])`, 'gi') } }) - const decorations: monaco.editor.IModelDeltaDecoration[] = [] - const lineCount = model.getLineCount() + const positions: Array<{ expr: string; line: number; startCol: number; endCol: number }> = [] let blockCommentState: BlockCommentState = false - for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) { - const lineContent = model.getLineContent(lineNumber) - const result = stripLineComments(lineContent, blockCommentState) + for (let lineNumber = 1; lineNumber <= model.getLineCount(); lineNumber++) { + const result = stripLineComments(model.getLineContent(lineNumber), blockCommentState) blockCommentState = result.state - const stripped = result.stripped - - // Track claimed column ranges to prevent overlapping decorations const claimed: Array<[number, number]> = [] - for (const { pattern, value } of exprPatterns) { + for (const { expr, pattern } of exprPatterns) { pattern.lastIndex = 0 let match: RegExpExecArray | null - while ((match = pattern.exec(stripped)) !== null) { + while ((match = pattern.exec(result.stripped)) !== null) { const startCol = match.index + 1 const endCol = startCol + match[0].length - - // Skip if overlapping with an already-claimed range if (claimed.some(([s, e]) => startCol < e && endCol > s)) continue - claimed.push([startCol, endCol]) - decorations.push({ - range: new monaco.Range(lineNumber, startCol, lineNumber, endCol), - options: { - after: { - content: ` = ${value} `, - inlineClassName: 'debug-inline-value', - }, - }, - }) + positions.push({ expr, line: lineNumber, startCol, endCol }) break // Only first occurrence per expression per line } } } - const collection = editorInstance.createDecorationsCollection(decorations) + return { prefix, positions } + }, [isDebuggerVisible, debugVarKeySet, language, name, fbInstanceContext]) + + // Phase 2: stamp current values onto cached positions (runs on each poll, O(positions) map lookups only) + useEffect(() => { + if (!debugVarPositions || !editorRef.current) return + + const { prefix, positions } = debugVarPositions + const decorations: monaco.editor.IModelDeltaDecoration[] = positions.map(({ expr, line, startCol, endCol }) => ({ + range: new monaco.Range(line, startCol, line, endCol), + options: { + after: { + content: ` = ${debugVariableValues.get(prefix + expr) ?? '?'} `, + inlineClassName: 'debug-inline-value', + }, + }, + })) + + const collection = editorRef.current.createDecorationsCollection(decorations) return () => collection.clear() - }, [isDebuggerVisible, debugVariableValues, pou?.data.body.value, language, name, fbInstanceContext]) + }, [debugVarPositions, debugVariableValues]) const variablesSuggestions = useCallback( (range: monaco.IRange) => {