Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions src/main/modules/ipc/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,12 @@ class MainProcessBridge implements MainIpcModule {
error?: string
needsReconnect?: boolean
}> => {
// If connection type is null, the debugger was intentionally disconnected.
// Return a silent failure so the renderer polling ignores it.
if (this.debuggerConnectionType === null) {
return { success: false, error: 'Debugger not connected' }
}

if (this.debuggerConnectionType === 'websocket') {
if (!this.debuggerWebSocketClient) {
if (this.debuggerReconnecting) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export const TooltipSidebarWrapperButton = ({
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipTrigger asChild>
<div className='w-full'>{children}</div>
</TooltipTrigger>
<SidebarTooltipContent side='right' sideOffset={5} arrow={false}>
<div className='w-full text-center font-caption text-xs'>{tooltipContent}</div>
</SidebarTooltipContent>
Expand Down
241 changes: 232 additions & 9 deletions src/renderer/components/_organisms/workspace-activity-bar/default.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { parsePlcStatus } from '@root/utils/plc-status'
import { addPythonLocalVariables } from '@root/utils/python/addPythonLocalVariables'
import { generateSTCode } from '@root/utils/python/generateSTCode'
import { injectPythonCode } from '@root/utils/python/injectPythonCode'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'

import {
DebuggerButton,
Expand Down Expand Up @@ -86,6 +86,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
const [isCompiling, setIsCompiling] = useState(false)
const [isDebuggerProcessing, setIsDebuggerProcessing] = useState(false)
const [simulatorRunning, setSimulatorRunning] = useState(false)
const pendingSimulatorDebugRef = useRef(false)

const disabledButtonClass = 'disabled cursor-not-allowed opacity-50 [&>*:first-child]:hover:bg-transparent'

Expand Down Expand Up @@ -115,7 +116,17 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
// (e.g. on project open/create) so the UI reflects the actual state.
useEffect(() => {
const cleanup = (window.bridge.onSimulatorStopped as (cb: () => void) => () => void)(() => {
pendingSimulatorDebugRef.current = false
setSimulatorRunning(false)
// Also clean up debugger state if it was connected via simulator
const { workspace, workspaceActions } = useOpenPLCStore.getState()
if (workspace.isDebuggerVisible) {
void window.bridge.debuggerDisconnect()
workspaceActions.setDebuggerVisible(false)
workspaceActions.setDebuggerTargetIp(null)
workspaceActions.setDebugForcedVariables(new Map())
workspaceActions.clearFbDebugContext()
}
})
return cleanup
}, [])
Expand Down Expand Up @@ -330,11 +341,18 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
;(window.bridge.simulatorLoadFirmware as (p: string) => Promise<{ success: boolean; error?: string }>)(
data.simulatorFirmwarePath,
)
.then((result) => {
.then(async (result) => {
if (result.success) {
setSimulatorRunning(true)
addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator is running.' })

// Auto-connect debugger after build when triggered from the Start button
if (pendingSimulatorDebugRef.current) {
pendingSimulatorDebugRef.current = false
await connectDebuggerAfterBuild()
Comment on lines +344 to +352

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

pendingSimulatorDebugRef.current not reset if handleRequest returns early before firmware.

handleSimulatorControl sets pendingSimulatorDebugRef.current = true before calling void verifyAndCompile(). If compilation aborts early inside handleRequest (e.g., CPP/Python validation failure at Lines 248–251 or 938–941), neither of those paths resets the flag. The firmware-path .catch() at Line 364 correctly resets it, but pre-firmware failure paths do not.

While this is benign today (Compile is disabled for simulator targets and a subsequent Start press unconditionally sets the flag back to true), explicitly resetting it in the early-exit paths of handleRequest would make the lifecycle easier to reason about.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/_organisms/workspace-activity-bar/default.tsx` around
lines 344 - 352, handleSimulatorControl sets pendingSimulatorDebugRef.current =
true before calling verifyAndCompile(), but handleRequest can return early on
validation failures without resetting that flag; update the early-exit paths in
handleRequest (the CPP/Python validation failure branches referenced around
where it returns before firmware handling) to explicitly set
pendingSimulatorDebugRef.current = false before returning so the flag is cleared
on all non-firmware/early-abort paths, leaving the existing .catch() that clears
it for firmware errors and preserving connectDebuggerAfterBuild behavior when
the build succeeds.

}
} else {
pendingSimulatorDebugRef.current = false
addLog({
id: crypto.randomUUID(),
level: 'error',
Expand All @@ -343,6 +361,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
}
})
.catch((err: unknown) => {
pendingSimulatorDebugRef.current = false
addLog({
id: crypto.randomUUID(),
level: 'error',
Expand Down Expand Up @@ -418,14 +437,25 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
const handleSimulatorControl = async (): Promise<void> => {
try {
if (simulatorRunning) {
// Stop: disconnect debugger first, then stop simulator
const { workspace, workspaceActions } = useOpenPLCStore.getState()
if (workspace.isDebuggerVisible) {
await window.bridge.debuggerDisconnect()
workspaceActions.setDebuggerVisible(false)
workspaceActions.setDebuggerTargetIp(null)
workspaceActions.setDebugForcedVariables(new Map())
workspaceActions.clearFbDebugContext()
}
await (window.bridge.simulatorStop as () => Promise<{ success: boolean }>)()
setSimulatorRunning(false)
addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator stopped.' })
} else {
// Re-build to get a fresh firmware and start the simulator
// Start: build, load firmware, then auto-connect debugger
pendingSimulatorDebugRef.current = true
void verifyAndCompile()
}
} catch (error) {
pendingSimulatorDebugRef.current = false
addLog({
id: crypto.randomUUID(),
level: 'error',
Expand All @@ -434,7 +464,200 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
}
}

const connectDebuggerAfterBuild = async () => {
const { project, workspaceActions, consoleActions } = useOpenPLCStore.getState()
const boardTarget = deviceDefinitions.configuration.deviceBoard
const projectPath = project.meta.path

consoleActions.addLog({
id: crypto.randomUUID(),
level: 'info',
message: 'Starting debugger for simulator...',
})

const debugFileResult = await window.bridge.readDebugFile(projectPath, boardTarget)

if (!debugFileResult.success || !debugFileResult.content) {
consoleActions.addLog({
id: crypto.randomUUID(),
level: 'error',
message: 'Failed to read debug.c file after compilation.',
})
return
}

const parsed = parseDebugFile(debugFileResult.content)
const indexMap = new Map<string, number>()
const instances = project.data.configuration.resource.instances

project.data.pous.forEach((pou) => {
if (pou.type !== 'program') return

const instance = instances.find((inst) => inst.program === pou.data.name)
if (!instance) {
consoleActions.addLog({
id: crypto.randomUUID(),
level: 'warning',
message: `No instance found for program '${pou.data.name}', skipping debug variable parsing.`,
})
return
}

pou.data.variables.forEach((v) => {
const index =
v.class === 'external'
? findGlobalVariableIndex(v.name, parsed.variables)
: findVariableIndexWithFallback(instance.name, v.name, parsed.variables)
if (index !== null) {
const compositeKey = `${pou.data.name}:${v.name}`
indexMap.set(compositeKey, index)
}
})
})

parsed.variables.forEach((debugVar) => {
if (!indexMap.has(debugVar.name)) {
indexMap.set(debugVar.name, debugVar.index)
}
})

try {
const trees: DebugTreeNode[] = []
const treeMap = new Map<string, DebugTreeNode>()
let complexCount = 0

const addNodeAndChildrenToMap = (node: DebugTreeNode) => {
treeMap.set(node.compositeKey, node)
if (node.children) {
for (const child of node.children) {
addNodeAndChildrenToMap(child)
}
}
}

project.data.pous.forEach((pou) => {
if (pou.type !== 'program') return

const instance = instances.find((inst) => inst.program === pou.data.name)
if (!instance) return

pou.data.variables.forEach((v) => {
try {
const node = buildDebugTree(v, pou.data.name, instance.name, parsed.variables, project)
trees.push(node)
addNodeAndChildrenToMap(node)
if (node.isComplex) {
complexCount++
}
} catch {
// Tree building failed for this variable
}
})
})

workspaceActions.setDebugVariableTree(treeMap)

if (process.env.NODE_ENV === 'development') {
;(window as Window & { debugTrees?: DebugTreeNode[] }).debugTrees = trees
}

consoleActions.addLog({
id: crypto.randomUUID(),
level: 'info',
message: `Debug tree builder: Built ${trees.length} trees (${complexCount} complex).`,
})
} catch {
consoleActions.addLog({
id: crypto.randomUUID(),
level: 'warning',
message: 'Debug tree builder encountered errors.',
})
}

// Build FB instance map for function block debugging
const fbDebugInstancesMap = new Map<string, FbInstanceInfo[]>()

const normalizeTypeString = (typeStr: string): string => {
return typeStr.toLowerCase().replace(/[-_]/g, '')
}

project.data.pous.forEach((pou) => {
if (pou.type !== 'program') return

const programInstance = instances.find((inst) => inst.program === pou.data.name)
if (!programInstance) return

pou.data.variables.forEach((v) => {
if (v.type.definition !== 'derived') return

const fbTypeNameRaw = v.type.value
const fbTypeKey = fbTypeNameRaw.toUpperCase()

const isStandardFB = StandardFunctionBlocks.pous.some(
(sfb) => sfb.name.toUpperCase() === fbTypeKey && normalizeTypeString(sfb.type) === 'functionblock',
)

const isCustomFB = project.data.pous.some(
(p) => normalizeTypeString(p.type) === 'functionblock' && p.data.name.toUpperCase() === fbTypeKey,
)

if (isStandardFB || isCustomFB) {
const instanceInfo: FbInstanceInfo = {
fbTypeName: fbTypeNameRaw,
programName: pou.data.name,
programInstanceName: programInstance.name,
fbVariableName: v.name,
key: `${pou.data.name}:${v.name}`,
}

const existingInstances = fbDebugInstancesMap.get(fbTypeKey) || []
existingInstances.push(instanceInfo)
fbDebugInstancesMap.set(fbTypeKey, existingInstances)
}
})
})

workspaceActions.setFbDebugInstances(fbDebugInstancesMap)

fbDebugInstancesMap.forEach((instanceList, fbTypeName) => {
if (instanceList.length > 0) {
workspaceActions.setFbSelectedInstance(fbTypeName, instanceList[0].key)
}
})

const fbTypesCount = fbDebugInstancesMap.size
const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0)
if (fbTypesCount > 0) {
consoleActions.addLog({
id: crypto.randomUUID(),
level: 'info',
message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`,
})
}

const connectResult: { success: boolean; error?: string } = await window.bridge.debuggerConnect('simulator', {})
if (!connectResult.success) {
consoleActions.addLog({
id: crypto.randomUUID(),
level: 'error',
message: `Failed to establish debugger connection: ${connectResult.error || 'Unknown error'}`,
})
return
}

workspaceActions.setDebugVariableIndexes(indexMap)
workspaceActions.setDebuggerVisible(true)
consoleActions.addLog({
id: crypto.randomUUID(),
level: 'info',
message: `Debugger started successfully. Found ${indexMap.size} debug variables.`,
})
}

const handleDebuggerClick = async () => {
// Simulator target uses the unified Start/Stop flow instead
if (isCurrentBoardSimulator) return

const { workspace, project, deviceDefinitions, workspaceActions, consoleActions, deviceActions } =
useOpenPLCStore.getState()

Expand Down Expand Up @@ -1250,10 +1473,10 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
<TooltipSidebarWrapperButton tooltipContent='Open/Close Toolbox'>
<ZoomButton {...zoom} />
</TooltipSidebarWrapperButton>
<TooltipSidebarWrapperButton tooltipContent='Compile'>
<TooltipSidebarWrapperButton tooltipContent={isCurrentBoardSimulator ? 'Use Start to build and run' : 'Compile'}>
<DownloadButton
disabled={isCompiling}
className={cn(isCompiling ? `${disabledButtonClass}` : '')}
disabled={isCompiling || isCurrentBoardSimulator}
className={cn((isCompiling || isCurrentBoardSimulator) && disabledButtonClass)}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={() => verifyAndCompile()}
/>
Expand Down Expand Up @@ -1287,12 +1510,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
{(isCurrentBoardSimulator ? simulatorRunning : plcStatus === 'RUNNING') ? <StopIcon /> : null}
</PlayButton>
</TooltipSidebarWrapperButton>
<TooltipSidebarWrapperButton tooltipContent='Debugger'>
<TooltipSidebarWrapperButton tooltipContent={isCurrentBoardSimulator ? 'Use Start to debug' : 'Debugger'}>
<DebuggerButton
onClick={() => void handleDebuggerClick()}
disabled={isDebuggerProcessing}
disabled={isDebuggerProcessing || isCurrentBoardSimulator}
isActive={isDebuggerVisible}
className={cn(isDebuggerProcessing && 'cursor-not-allowed opacity-50')}
className={cn((isDebuggerProcessing || isCurrentBoardSimulator) && 'cursor-not-allowed opacity-50')}
/>
</TooltipSidebarWrapperButton>
Comment on lines +1297 to 1304

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

DebuggerButton disabled styling is inconsistent with disabledButtonClass.

Line 1300 uses 'cursor-not-allowed opacity-50' directly, missing the [&>*:first-child]:hover:bg-transparent class present in disabledButtonClass (line 95). The hover state will remain active on the inner element, inconsistent with DownloadButton at line 1261.

🐛 Proposed fix
-          className={cn((isDebuggerProcessing || isCurrentBoardSimulator) && 'cursor-not-allowed opacity-50')}
+          className={cn((isDebuggerProcessing || isCurrentBoardSimulator) && disabledButtonClass)}
📝 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
<TooltipSidebarWrapperButton tooltipContent={isCurrentBoardSimulator ? 'Use Start to debug' : 'Debugger'}>
<DebuggerButton
onClick={() => void handleDebuggerClick()}
disabled={isDebuggerProcessing}
disabled={isDebuggerProcessing || isCurrentBoardSimulator}
isActive={isDebuggerVisible}
className={cn(isDebuggerProcessing && 'cursor-not-allowed opacity-50')}
className={cn((isDebuggerProcessing || isCurrentBoardSimulator) && 'cursor-not-allowed opacity-50')}
/>
</TooltipSidebarWrapperButton>
<TooltipSidebarWrapperButton tooltipContent={isCurrentBoardSimulator ? 'Use Start to debug' : 'Debugger'}>
<DebuggerButton
onClick={() => void handleDebuggerClick()}
disabled={isDebuggerProcessing || isCurrentBoardSimulator}
isActive={isDebuggerVisible}
className={cn((isDebuggerProcessing || isCurrentBoardSimulator) && disabledButtonClass)}
/>
</TooltipSidebarWrapperButton>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/_organisms/workspace-activity-bar/default.tsx` around
lines 1295 - 1302, The DebuggerButton's disabled styling uses a hardcoded class
string and omits the sibling/child hover suppression in disabledButtonClass;
update the className logic on DebuggerButton (inside
TooltipSidebarWrapperButton) to use the same disabledButtonClass when
(isDebuggerProcessing || isCurrentBoardSimulator) is true (or add the missing
[&>*:first-child]:hover:bg-transparent to the existing class string) so the
inner element's hover state is disabled consistently like DownloadButton.

</>
Expand Down