Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 145 additions & 1 deletion src/renderer/components/_features/[workspace]/editor/monaco/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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++
}
Comment on lines +71 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/_features/`[workspace]/editor/monaco/index.tsx around
lines 71 - 105, The stripLineComments function incorrectly treats comment
markers inside string literals as comments; update stripLineComments to track a
stringLiteral state (e.g., false | "'" | '"' ) alongside BlockCommentState and
skip recognizing '//' , '(*', '*)', '/*', '*/' when inside a string, handling
escape/backslash so an escaped quote doesn't end the string; modify the while
loop logic in stripLineComments to toggle stringLiteral on unescaped quotes and
only enter or exit block/line comment logic when stringLiteral is false so
inline strings like 'http://...' are preserved.

}
}

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)
Expand All @@ -78,6 +122,10 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType<typeof PrimitiveEdit
regularExpression,
workspace: {
systemConfigs: { shouldUseDarkMode },
isDebuggerVisible,
debugVariableValues,
fbSelectedInstance,
fbDebugInstances,
},
project: {
meta: { path: projectPath },
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Only the first same-expression occurrence per line gets a badge.

break at Line 353 stops scanning after one match per expression, so repeated uses on the same line are skipped.

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
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/_features/`[workspace]/editor/monaco/index.tsx around
lines 347 - 354, The loop that finds matches for each expression stops after the
first match due to the break, causing only one badge per expression per line;
remove the break inside the while ((match = pattern.exec(result.stripped)) !==
null) loop so the code continues scanning for additional non-overlapping matches
(respecting the claimed array check) and pushes multiple positions for the same
expr on the same line (keep using claimed, positions.push, startCol/endCol, and
lineNumber as-is).

}
}

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({
Expand Down Expand Up @@ -826,6 +969,7 @@ void loop()
dropIntoEditor: {
enabled: true,
},
readOnly: isDebuggerVisible,
}

const handleDrop = (ev: React.DragEvent<HTMLDivElement>) => {
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.dark .oplc-monaco-wrapper .debug-inline-value {
background-color: #388e3c;
}
.dark .oplc-monaco-wrapper .debug-inline-value {
background-color: `#2e7d32`;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/styles/globals.css` around lines 198 - 200, The dark-mode inline
debug badge (.dark .oplc-monaco-wrapper .debug-inline-value) uses a too-light
green; update its background-color to a slightly darker, higher-contrast shade
(for example `#2e7d32` or a similar darker green) so 11px white text meets
accessibility/contrast and remains readable in dark mode.


/* Ensure Radix popper/select content sits well above Monaco */
[data-radix-popper-content-wrapper],
[data-radix-select-content],
Expand Down