From 867f60226843bc27a93ea949d8a35dba52b14b44 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 9 Jan 2026 20:32:45 -0500 Subject: [PATCH 1/3] feat: Add all variable types to global variables table Bring the same types from program POU variable table to global variables: - Add function block types (system and user libraries) - Add array type support with GlobalArrayModal - Add search/filter functionality for all type categories This matches the functionality available in the regular POU variable table. Co-Authored-By: Claude Opus 4.5 --- .../elements/array-modal.tsx | 194 ++++++++++++++++ .../selectable-cell.tsx | 216 +++++++++++++++--- 2 files changed, 377 insertions(+), 33 deletions(-) create mode 100644 src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx diff --git a/src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx b/src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx new file mode 100644 index 000000000..b94a3ae60 --- /dev/null +++ b/src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx @@ -0,0 +1,194 @@ +import { DimensionsModal } from '@root/renderer/components/_atoms/dimensions-modal' +import { toast } from '@root/renderer/components/_features/[app]/toast/use-toast' +import { useOpenPLCStore } from '@root/renderer/store' +import { arrayValidation } from '@root/renderer/store/slices/workspace/utils/variables' +import { BaseType, baseTypeSchema } from '@root/types/PLC/open-plc' +import { useEffect, useState } from 'react' + +type ArrayModalProps = { + variableName: string + variableRow?: number + arrayModalIsOpen: boolean + setArrayModalIsOpen: (value: boolean) => void + closeContainer: () => void +} + +type Pou = { type: string; name: string } +type UserLibWithPous = { pous: Pou[] } +type UserLibFunctionBlock = { type: string; name: string } + +export const GlobalArrayModal = ({ + arrayModalIsOpen, + closeContainer, + setArrayModalIsOpen, + variableName, + variableRow, +}: ArrayModalProps) => { + const { + project: { + data: { + dataTypes, + configuration: { + resource: { globalVariables }, + }, + }, + }, + projectActions: { updateVariable }, + libraries: sliceLibraries, + } = useOpenPLCStore() + + const baseTypes = baseTypeSchema.options.filter((type) => type.toUpperCase() !== 'ARRAY') + + const userDataTypes = dataTypes.map((type) => type.name).filter((typeName) => typeName.toUpperCase() !== 'ARRAY') + + const systemFunctionBlocks = sliceLibraries.system.flatMap((lib) => + lib.pous.filter((pou) => pou.type === 'function-block').map((pou) => pou.name.toUpperCase()), + ) + + const userFunctionBlocks = sliceLibraries.user.flatMap((userLib: UserLibWithPous | UserLibFunctionBlock) => + 'pous' in userLib && Array.isArray(userLib.pous) + ? userLib.pous.filter((pou) => pou.type === 'function-block').map((pou) => pou.name.toUpperCase()) + : (userLib as UserLibFunctionBlock).type === 'function-block' + ? [(userLib as UserLibFunctionBlock).name.toUpperCase()] + : [], + ) + + const VariableTypes = [ + { definition: 'base-type', values: baseTypes }, + { definition: 'user-data-type', values: userDataTypes }, + ] + + const LibraryTypes = [ + { definition: 'system', values: systemFunctionBlocks }, + { definition: 'user', values: userFunctionBlocks }, + ] + + const [selectedInput, setSelectedInput] = useState('') + const [dimensions, setDimensions] = useState([]) + const [typeValue, setTypeValue] = useState('dint') + + useEffect(() => { + const variable = globalVariables.find((variable) => variable.name === variableName) + if (!variable) return + + if (variable.type.definition === 'array') { + setDimensions(variable.type.data.dimensions.map((dimension) => dimension.dimension)) + setTypeValue(variable.type.data.baseType.value) + } else { + setDimensions([]) + setTypeValue('dint') + } + }, [variableName, globalVariables]) + + const handleAddDimension = () => { + setDimensions((prev) => [...prev, '']) + setSelectedInput(dimensions.length.toString()) + } + + const handleRemoveDimension = (index: string) => { + setDimensions((prev) => [...prev.slice(0, Number(index)), ...prev.slice(Number(index) + 1)]) + setSelectedInput('') + } + + const handleRearrangeDimensions = (index: number, direction: 'up' | 'down') => { + if (direction === 'up') { + if (index === 0) return + const [removed] = dimensions.splice(index, 1) + dimensions.splice(index - 1, 0, removed) + setSelectedInput((index - 1).toString()) + return + } + + if (index === dimensions.length - 1) return + const [removed] = dimensions.splice(index, 1) + dimensions.splice(index + 1, 0, removed) + setSelectedInput((index + 1).toString()) + } + + const handleUpdateType = (value: BaseType) => { + setTypeValue(value) + } + + const handleUpdateDimension = (index: number, value: string): { ok: boolean } => { + const res = arrayValidation({ value: value }) + if (!res.ok) { + toast({ + title: res.title, + description: res.message, + variant: 'fail', + }) + return { ok: false } + } + setDimensions((prev) => [...prev.slice(0, index), value, ...prev.slice(index + 1)]) + return { ok: true } + } + + const handleInputClick = (value: string) => { + setSelectedInput(value) + } + + const handleSave = () => { + const dimensionToSave = dimensions.filter((value) => value !== '') + if (dimensionToSave.length === 0) { + toast({ + title: 'Invalid array', + description: 'Array must have at least one not empty dimension', + variant: 'fail', + }) + return + } + const formatArrayName = `ARRAY [${dimensionToSave.join(', ')}] OF ${typeValue?.toUpperCase()}` + + let isBaseType = false + baseTypes.forEach((type) => { + if (type === typeValue) isBaseType = true + }) + + updateVariable({ + scope: 'global', + rowId: variableRow, + data: { + type: { + definition: 'array', + value: formatArrayName, + data: { + // @ts-expect-error - This is a valid operation. This is being fixed. + baseType: { + definition: isBaseType ? 'base-type' : 'user-data-type', + value: typeValue, + }, + dimensions: dimensionToSave.map((dimension) => ({ dimension: dimension })), + }, + }, + }, + }) + setArrayModalIsOpen(false) + closeContainer() + } + + const handleCancel = () => { + setArrayModalIsOpen(false) + closeContainer() + } + + return ( + + ) +} diff --git a/src/renderer/components/_molecules/global-variables-table/selectable-cell.tsx b/src/renderer/components/_molecules/global-variables-table/selectable-cell.tsx index b43f6b6b4..afa144dac 100644 --- a/src/renderer/components/_molecules/global-variables-table/selectable-cell.tsx +++ b/src/renderer/components/_molecules/global-variables-table/selectable-cell.tsx @@ -13,8 +13,9 @@ import type { CellContext } from '@tanstack/react-table' import _ from 'lodash' import { useEffect, useState } from 'react' -import { Select, SelectContent, SelectItem, SelectTrigger } from '../../_atoms' +import { InputWithRef, Select, SelectContent, SelectItem, SelectTrigger } from '../../_atoms' import { TypeChangeModal } from '../type-change-modal' +import { GlobalArrayModal } from './elements/array-modal' type ISelectableCellProps = CellContext & { editable?: boolean } @@ -53,6 +54,7 @@ const SelectableTypeCell = ({ ladderFlows, fbdFlows, projectActions: { updateVariable }, + libraries: sliceLibraries, } = useOpenPLCStore() const VariableTypes = [ @@ -62,10 +64,33 @@ const SelectableTypeCell = ({ }, { definition: 'user-data-type', values: dataTypes.map((dataType) => dataType.name) }, ] + + const LibraryTypes = [ + { + definition: 'system', + values: sliceLibraries.system.flatMap((library) => + library.pous.filter((pou) => pou.type === 'function-block').map((pou) => pou.name.toUpperCase()), + ), + }, + { + definition: 'user', + values: sliceLibraries.user.flatMap((userLibrary) => + 'pous' in userLibrary && Array.isArray((userLibrary as { pous: { type: string; name: string }[] }).pous) + ? (userLibrary as { pous: { type: string; name: string }[] }).pous + .filter((pou) => pou.type === 'function-block') + .map((pou) => pou.name.toUpperCase()) + : userLibrary.type === 'function-block' + ? [userLibrary.name.toUpperCase()] + : [], + ), + }, + ] + const { value, definition } = getValue() const [cellValue, setCellValue] = useState(value) const [poppoverIsOpen, setPoppoverIsOpen] = useState(false) + const [arrayModalIsOpen, setArrayModalIsOpen] = useState(false) const [typeChangeModalOpen, setTypeChangeModalOpen] = useState(false) const [pendingTypeChange, setPendingTypeChange] = useState<{ definition: PLCVariable['type']['definition'] @@ -73,6 +98,32 @@ const SelectableTypeCell = ({ } | null>(null) const [validationResult, setValidationResult] = useState(null) + const [variableFilters, setVariableFilters] = useState>({ + 'base-type': '', + 'user-data-type': '', + }) + const [libraryFilter, setLibraryFilter] = useState('') + + const filteredBaseTypes = + VariableTypes.find((v) => v.definition === 'base-type')?.values.filter((val) => + val.toUpperCase().includes(variableFilters['base-type'].toUpperCase()), + ) || [] + + const filteredUserDataTypes = + VariableTypes.find((v) => v.definition === 'user-data-type')?.values.filter((val) => + val.toUpperCase().includes(variableFilters['user-data-type'].toUpperCase()), + ) || [] + + const filteredSystemLibraries = + LibraryTypes.find((l) => l.definition === 'system')?.values.filter((val) => + val.toUpperCase().includes(libraryFilter.toUpperCase()), + ) || [] + + const filteredUserLibraries = + LibraryTypes.find((l) => l.definition === 'user')?.values.filter((val) => + val.toUpperCase().includes(libraryFilter.toUpperCase()), + ) || [] + const currentVariable = table.options.data[index] const variableName = currentVariable.name @@ -151,6 +202,13 @@ const SelectableTypeCell = ({ /> ) })()} + setPoppoverIsOpen(false)} + />
- {VariableTypes.map((scope) => ( - - -
- - {_.startCase(scope.definition)} - - -
-
- - - {scope.values.map((value) => ( - onSelect(scope.definition as PLCGlobalVariable['type']['definition'], value)} - className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' - > - - {_.upperCase(value)} - - - ))} - - -
- ))} - - + {VariableTypes.map((scope) => { + const filterText = variableFilters[scope.definition] || '' + const filteredValues = scope.definition === 'base-type' ? filteredBaseTypes : filteredUserDataTypes + return ( + setVariableFilters((prev) => ({ ...prev, [scope.definition]: '' }))} + > + +
+ + {_.startCase(scope.definition)} + + +
+
+ + +
+ + setVariableFilters((prev) => ({ + ...prev, + [scope.definition]: e.target.value, + })) + } + onKeyDown={(e) => e.stopPropagation()} + /> +
+ {filteredValues.length > 0 ? ( + filteredValues.map((value) => ( + + onSelect(scope.definition as PLCGlobalVariable['type']['definition'], value) + } + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {_.upperCase(value)} + + + )) + ) : ( +
+ + No {_.startCase(scope.definition)} found + +
+ )} +
+
+
+ ) + })} + + { + setArrayModalIsOpen(true) + setPoppoverIsOpen(false) + }} + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 data-[state=open]:bg-neutral-100 dark:hover:bg-neutral-900 data-[state=open]:dark:bg-neutral-900' + > + Array + + + {LibraryTypes.map((scope) => { + const filteredValues = scope.definition === 'system' ? filteredSystemLibraries : filteredUserLibraries + return ( + setLibraryFilter('')}> + +
+ + {_.startCase(scope.definition)} + + +
+
+ + +
+ setLibraryFilter(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ {filteredValues.length > 0 ? ( + filteredValues.map((value) => ( + onSelect('derived', value)} + className='flex h-8 w-full cursor-pointer items-center justify-center py-1 outline-none hover:bg-neutral-100 dark:hover:bg-neutral-900' + > + + {_.upperCase(value)} + + + )) + ) : ( +
+ + No {_.startCase(scope.definition)} found + +
+ )} +
+
+
+ ) + })} From 4cf15f4a4ab7d91a1e459b3448ecc91fe5140e03 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 9 Jan 2026 21:17:56 -0500 Subject: [PATCH 2/3] fix: Map external variables to global variables in debugger When forcing or viewing an external variable in a POU, the debugger now correctly maps it to the corresponding global variable (CONFIG0__ prefix) instead of looking for a local POU variable (RES0__ prefix). This ensures that: - Forcing an external variable affects the actual global variable - All POUs with the same external variable see the same value - Global variables can be properly debugged Changes: - Updated matchVariableWithDebugEntry to handle external variables - Added matchGlobalVariableWithDebugEntry for direct global var matching - Updated buildDebugTree and related functions to use correct paths for external variables (CONFIG0__ vs RES0__) Co-Authored-By: Claude Opus 4.5 --- .../workspace-activity-bar/default.tsx | 2 +- src/renderer/utils/debug-tree-builder.ts | 35 +++++++++++-------- src/renderer/utils/parse-debug-file.ts | 28 ++++++++++++++- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/renderer/components/_organisms/workspace-activity-bar/default.tsx b/src/renderer/components/_organisms/workspace-activity-bar/default.tsx index cc1d33951..c54d3a7f7 100644 --- a/src/renderer/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/renderer/components/_organisms/workspace-activity-bar/default.tsx @@ -885,7 +885,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const allVariables = pou.data.variables allVariables.forEach((v) => { - const index = matchVariableWithDebugEntry(v.name, instance.name, parsed.variables) + const index = matchVariableWithDebugEntry(v.name, instance.name, parsed.variables, v.class) if (index !== null) { const compositeKey = `${pou.data.name}:${v.name}` indexMap.set(compositeKey, index) diff --git a/src/renderer/utils/debug-tree-builder.ts b/src/renderer/utils/debug-tree-builder.ts index 6d425a9ae..90cc7eff3 100644 --- a/src/renderer/utils/debug-tree-builder.ts +++ b/src/renderer/utils/debug-tree-builder.ts @@ -44,6 +44,21 @@ function logDebugTree(node: DebugTreeNode, indent = 0): void { } } +/** + * Builds the base path for a variable based on whether it's external (global) or local. + * External variables use CONFIG0__ prefix, local variables use RES0__INSTANCE. prefix. + */ +function buildVariableBasePath(variableName: string, instanceName: string, variableClass?: string): string { + const variableNameUpper = variableName.toUpperCase() + if (variableClass === 'external') { + // External variables reference global variables, which use CONFIG0__ prefix + return `CONFIG0__${variableNameUpper}` + } + // Regular POU variables use RES0__INSTANCE.VARNAME format + const instanceNameUpper = instanceName.toUpperCase() + return `RES0__${instanceNameUpper}.${variableNameUpper}` +} + /** * Builds a debug tree structure for a PLC variable. * Recursively processes complex types (arrays, structs, function blocks). @@ -62,16 +77,13 @@ export function buildDebugTree( debugVariables: DebugVariable[], project: PLCProject, ): DebugTreeNode { - const variableName = variable.name.toUpperCase() - const instanceNameUpper = instanceName.toUpperCase() - const compositeKey = `${pouName}:${variable.name}` let node: DebugTreeNode if (variable.type.definition === 'base-type') { const baseType = variable.type.value.toUpperCase() - const fullPath = `RES0__${instanceNameUpper}.${variableName}` + const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class) const debugVar = debugVariables.find((dv) => dv.name === fullPath) @@ -92,7 +104,7 @@ export function buildDebugTree( } else { node = { name: variable.name, - fullPath: `RES0__${instanceNameUpper}.${variableName}`, + fullPath: buildVariableBasePath(variable.name, instanceName, variable.class), compositeKey, type: 'UNKNOWN', isComplex: false, @@ -125,11 +137,9 @@ function buildFunctionBlockTree( const fbTypeName = variable.type.value const fbTypeNameUpper = fbTypeName.toUpperCase() - const variableName = variable.name.toUpperCase() - const instanceNameUpper = instanceName.toUpperCase() const compositeKey = `${pouName}:${variable.name}` - const fullPath = `RES0__${instanceNameUpper}.${variableName}` + const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class) const standardFB = StandardFunctionBlocks.pous.find( (pou) => pou.name.toUpperCase() === fbTypeNameUpper && normalizeTypeString(pou.type) === 'functionblock', @@ -566,11 +576,8 @@ function buildArrayTree( throw new Error('Expected array type') } - const variableName = variable.name.toUpperCase() - const instanceNameUpper = instanceName.toUpperCase() - const compositeKey = `${pouName}:${variable.name}` - const fullPath = `RES0__${instanceNameUpper}.${variableName}` + const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class) const dimensions = variable.type.data.dimensions if (dimensions.length === 0) { @@ -657,11 +664,9 @@ function buildStructTree( const structTypeName = variable.type.value const structTypeNameUpper = structTypeName.toUpperCase() - const variableName = variable.name.toUpperCase() - const instanceNameUpper = instanceName.toUpperCase() const compositeKey = `${pouName}:${variable.name}` - const fullPath = `RES0__${instanceNameUpper}.${variableName}` + const fullPath = buildVariableBasePath(variable.name, instanceName, variable.class) const structType = project.data.dataTypes.find( (dt) => dt.name.toUpperCase() === structTypeNameUpper && dt.derivation === 'structure', diff --git a/src/renderer/utils/parse-debug-file.ts b/src/renderer/utils/parse-debug-file.ts index 71e9407c5..6193e1a5a 100644 --- a/src/renderer/utils/parse-debug-file.ts +++ b/src/renderer/utils/parse-debug-file.ts @@ -126,13 +126,39 @@ export function matchVariableWithDebugEntry( pouVariableName: string, instanceName: string, debugVariables: DebugVariable[], + variableClass?: string, ): number | null { - const instanceNameUpper = instanceName.toUpperCase() const variableNameUpper = pouVariableName.toUpperCase() + // For external variables, match against the global variable (CONFIG0__VARNAME) + // This ensures forcing an external variable affects the actual global variable + if (variableClass === 'external') { + const globalPath = `CONFIG0__${variableNameUpper}` + const match = debugVariables.find((dv) => dv.name === globalPath) + return match ? match.index : null + } + + // For regular POU variables, use the instance path (RES0__INSTANCE.VARNAME) + const instanceNameUpper = instanceName.toUpperCase() const expectedPath = `RES0__${instanceNameUpper}.${variableNameUpper}` const match = debugVariables.find((dv) => dv.name === expectedPath) return match ? match.index : null } + +/** + * Match a global variable with its debug entry. + * Global variables use CONFIG0__ prefix. + */ +export function matchGlobalVariableWithDebugEntry( + globalVariableName: string, + debugVariables: DebugVariable[], +): number | null { + const variableNameUpper = globalVariableName.toUpperCase() + const expectedPath = `CONFIG0__${variableNameUpper}` + + const match = debugVariables.find((dv) => dv.name === expectedPath) + + return match ? match.index : null +} From 36ec9773929d2162b42c21fe28af9fc6ddbbdd76 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 9 Jan 2026 23:42:15 -0500 Subject: [PATCH 3/3] refactor: Address code review feedback - Fix handleRearrangeDimensions to use immutable state updates (create copy before using splice instead of mutating state directly) - Simplify isBaseType check using includes() instead of forEach loop Co-Authored-By: Claude Opus 4.5 --- .../elements/array-modal.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx b/src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx index b94a3ae60..4f52c03e2 100644 --- a/src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx +++ b/src/renderer/components/_molecules/global-variables-table/elements/array-modal.tsx @@ -93,15 +93,19 @@ export const GlobalArrayModal = ({ const handleRearrangeDimensions = (index: number, direction: 'up' | 'down') => { if (direction === 'up') { if (index === 0) return - const [removed] = dimensions.splice(index, 1) - dimensions.splice(index - 1, 0, removed) + const newDimensions = [...dimensions] + const [removed] = newDimensions.splice(index, 1) + newDimensions.splice(index - 1, 0, removed) + setDimensions(newDimensions) setSelectedInput((index - 1).toString()) return } if (index === dimensions.length - 1) return - const [removed] = dimensions.splice(index, 1) - dimensions.splice(index + 1, 0, removed) + const newDimensions = [...dimensions] + const [removed] = newDimensions.splice(index, 1) + newDimensions.splice(index + 1, 0, removed) + setDimensions(newDimensions) setSelectedInput((index + 1).toString()) } @@ -139,10 +143,7 @@ export const GlobalArrayModal = ({ } const formatArrayName = `ARRAY [${dimensionToSave.join(', ')}] OF ${typeValue?.toUpperCase()}` - let isBaseType = false - baseTypes.forEach((type) => { - if (type === typeValue) isBaseType = true - }) + const isBaseType = baseTypes.includes(typeValue) updateVariable({ scope: 'global',