-
Notifications
You must be signed in to change notification settings - Fork 62
feat: add live debug inline values for ST/IL editor #639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<typeof PrimitiveEditor> => { | ||
| const { language, path, name } = props | ||
| const editorRef = useRef<null | monaco.editor.IStandaloneCodeEditor>(null) | ||
|
|
@@ -78,6 +122,10 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType<typeof PrimitiveEdit | |
| regularExpression, | ||
| workspace: { | ||
| systemConfigs: { shouldUseDarkMode }, | ||
| isDebuggerVisible, | ||
| debugVariableValues, | ||
| fbSelectedInstance, | ||
| fbDebugInstances, | ||
| }, | ||
| project: { | ||
| meta: { path: projectPath }, | ||
|
|
@@ -234,6 +282,101 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType<typeof PrimitiveEdit | |
| } | ||
| }, [pou?.type, name, language]) | ||
|
|
||
| // Update readOnly when debugger visibility changes on an already-mounted editor | ||
| useEffect(() => { | ||
| 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]) | ||
|
|
||
| // 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 prefix = fbInstanceContext | ||
| ? `${fbInstanceContext.programName}:${fbInstanceContext.fbVariableName}.` | ||
| : `${name}:` | ||
|
|
||
| // 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 (varNames.length === 0) return null | ||
|
|
||
| // Sort longest first so "TON0.Q" is matched before "TON0" on the same line | ||
| varNames.sort((a, b) => b.length - a.length) | ||
|
|
||
| const exprPatterns = varNames.map((expr) => { | ||
| const escaped = expr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | ||
| return { expr, pattern: new RegExp(`\\b${escaped}(?![\\w.\\[])`, 'gi') } | ||
| }) | ||
|
|
||
| const positions: Array<{ expr: string; line: number; startCol: number; endCol: number }> = [] | ||
| let blockCommentState: BlockCommentState = false | ||
|
|
||
| for (let lineNumber = 1; lineNumber <= model.getLineCount(); lineNumber++) { | ||
| const result = stripLineComments(model.getLineContent(lineNumber), blockCommentState) | ||
| blockCommentState = result.state | ||
| const claimed: Array<[number, number]> = [] | ||
|
|
||
| for (const { expr, pattern } of exprPatterns) { | ||
| pattern.lastIndex = 0 | ||
| let match: RegExpExecArray | null | ||
| while ((match = pattern.exec(result.stripped)) !== null) { | ||
| const startCol = match.index + 1 | ||
| const endCol = startCol + match[0].length | ||
| if (claimed.some(([s, e]) => startCol < e && endCol > s)) continue | ||
| claimed.push([startCol, endCol]) | ||
| positions.push({ expr, line: lineNumber, startCol, endCol }) | ||
| break // Only first occurrence per expression per line | ||
| } | ||
|
Comment on lines
+347
to
+354
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only the first same-expression occurrence per line gets a badge.
Proposed fix while ((match = pattern.exec(result.stripped)) !== null) {
const startCol = match.index + 1
const endCol = startCol + match[0].length
if (claimed.some(([s, e]) => startCol < e && endCol > s)) continue
claimed.push([startCol, endCol])
positions.push({ expr, line: lineNumber, startCol, endCol })
- break // Only first occurrence per expression per line
}🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| 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() | ||
| }, [debugVarPositions, debugVariableValues]) | ||
|
|
||
| const variablesSuggestions = useCallback( | ||
| (range: monaco.IRange) => { | ||
| const suggestions = tableVariablesCompletion({ | ||
|
|
@@ -826,6 +969,7 @@ void loop() | |
| dropIntoEditor: { | ||
| enabled: true, | ||
| }, | ||
| readOnly: isDebuggerVisible, | ||
| } | ||
|
|
||
| const handleDrop = (ev: React.DragEvent<HTMLDivElement>) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+198
to
+200
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Increase dark-mode badge contrast for small text. Line 199 uses a lighter green that can drop readability for 11px white text. A slightly darker shade would improve accessibility. 💡 Suggested tweak .dark .oplc-monaco-wrapper .debug-inline-value {
- background-color: `#388e3c`;
+ background-color: `#2e7d32`;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| /* Ensure Radix popper/select content sits well above Monaco */ | ||||||||||||||
| [data-radix-popper-content-wrapper], | ||||||||||||||
| [data-radix-select-content], | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard comment parsing against string literals.
Line 89, Line 93, and Line 98 currently treat comment markers as comments even when they appear inside ST/IL strings (e.g.,
'http://...'), which can suppress valid inline decorations later on the same line.💡 Suggested fix
function stripLineComments(line: string, state: BlockCommentState): { stripped: string; state: BlockCommentState } { const chars = [...line] let i = 0 let s = state + let inString = false while (i < chars.length) { if (s) { const endMarker = s === 'paren' ? ')' : '/' if (chars[i] === '*' && chars[i + 1] === endMarker) { @@ } else { + if (chars[i] === "'") { + // ST escaped quote inside string: '' + if (inString && chars[i + 1] === "'") { + i += 2 + continue + } + inString = !inString + i++ + continue + } + + if (inString) { + i++ + continue + } + if (chars[i] === '/' && chars[i + 1] === '/') { for (let j = i; j < chars.length; j++) chars[j] = ' ' break }🤖 Prompt for AI Agents