From 81e26cc053eea0cbedaa5005d0255ecf105cc159 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:55:47 -0300 Subject: [PATCH 01/19] perf: port debug session rendering and polling optimizations from web PR #348 Port comprehensive performance optimizations that reduce main-thread CPU usage during simulator debugging. Shared surfaces synced byte-identical with openplc-web. - Targeted Zustand selectors for all debug-aware components - BOOL/non-BOOL value split so canvas only re-renders on BOOL changes - Change-driven store writes (zero updates when values unchanged) - Re-render cascade elimination via targeted selectors in layout tree - buildActiveIndexSet memoization (O(1) per poll tick instead of O(n)) - Redundant setDebugDataStale(false) guard - Move backend/styles to backend/shared/styles Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 2 +- src/backend/{ => shared}/styles/globals.css | 0 .../block-output-debug-badges.tsx | 6 +- .../graphical-editor/debug-value-badge.tsx | 8 +- .../_atoms/graphical-editor/fbd/variable.tsx | 112 ++++------------ .../_atoms/graphical-editor/ladder/coil.tsx | 82 +++--------- .../graphical-editor/ladder/contact.tsx | 82 +++--------- .../graphical-editor/ladder/variable.tsx | 70 ++-------- .../[workspace]/editor/monaco/index.tsx | 18 ++- .../_molecules/graphical-editor/fbd/index.tsx | 9 +- .../graphical-editor/ladder/rung/body.tsx | 4 +- .../_molecules/variables-panel/index.tsx | 8 +- .../components/_organisms/debugger/index.tsx | 16 ++- .../workspace-activity-bar/index.tsx | 8 +- .../_templates/[workspace]/main-content.tsx | 8 +- .../components/_templates/app-layout.tsx | 12 +- src/frontend/hooks/use-debug-composite-key.ts | 29 +++-- src/frontend/hooks/use-debug-value.ts | 100 +++++++++++++++ src/frontend/hooks/useDebugPolling.ts | 57 +++++++-- src/frontend/screens/workspace-screen.tsx | 121 +++++++++++------- src/frontend/services/debug-force-variable.ts | 49 +++++++ .../store/__tests__/workspace-slice.test.ts | 43 +++++-- src/frontend/store/slices/workspace/slice.ts | 25 +++- src/frontend/store/slices/workspace/types.ts | 6 +- 24 files changed, 451 insertions(+), 424 deletions(-) rename src/backend/{ => shared}/styles/globals.css (100%) create mode 100644 src/frontend/hooks/use-debug-value.ts create mode 100644 src/frontend/services/debug-force-variable.ts diff --git a/src/App.tsx b/src/App.tsx index 3068e21b4..d3512da51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import '@xyflow/react/dist/style.css' import 'tailwindcss/tailwind.css' -import './backend/styles/globals.css' +import './backend/shared/styles/globals.css' import { useEffect } from 'react' diff --git a/src/backend/styles/globals.css b/src/backend/shared/styles/globals.css similarity index 100% rename from src/backend/styles/globals.css rename to src/backend/shared/styles/globals.css diff --git a/src/frontend/components/_atoms/graphical-editor/block-output-debug-badges.tsx b/src/frontend/components/_atoms/graphical-editor/block-output-debug-badges.tsx index e66868098..175a8bfc4 100644 --- a/src/frontend/components/_atoms/graphical-editor/block-output-debug-badges.tsx +++ b/src/frontend/components/_atoms/graphical-editor/block-output-debug-badges.tsx @@ -1,5 +1,5 @@ import { useDebugCompositeKey } from '../../../hooks/use-debug-composite-key' -import { useOpenPLCStore } from '../../../store' +import { useIsDebuggerVisible } from '../../../hooks/use-debug-value' import { DebugValueBadge } from './debug-value-badge' type BlockOutputDebugBadgesProps = { @@ -34,9 +34,7 @@ const BlockOutputDebugBadges = ({ blockWidth, connectedOutputNames, }: BlockOutputDebugBadgesProps) => { - const { - workspace: { isDebuggerVisible }, - } = useOpenPLCStore() + const isDebuggerVisible = useIsDebuggerVisible() const getCompositeKey = useDebugCompositeKey() if (!isDebuggerVisible || blockType === 'generic') { diff --git a/src/frontend/components/_atoms/graphical-editor/debug-value-badge.tsx b/src/frontend/components/_atoms/graphical-editor/debug-value-badge.tsx index 9afae7e22..9f7b72b38 100644 --- a/src/frontend/components/_atoms/graphical-editor/debug-value-badge.tsx +++ b/src/frontend/components/_atoms/graphical-editor/debug-value-badge.tsx @@ -1,4 +1,4 @@ -import { useOpenPLCStore } from '../../../store' +import { useDebugVariableValue } from '../../../hooks/use-debug-value' import { cn } from '../../../utils/cn' type DebugValueBadgeProps = { @@ -15,15 +15,11 @@ type DebugValueBadgeProps = { * Designed to be used by both FBD and LD variable/block nodes. */ const DebugValueBadge = ({ compositeKey, variableType, position = 'right' }: DebugValueBadgeProps) => { - const { - workspace: { debugVariableValues }, - } = useOpenPLCStore() + const value = useDebugVariableValue(compositeKey) if (!variableType || variableType.toUpperCase() === 'BOOL') { return null } - - const value = debugVariableValues.get(compositeKey) if (value === undefined) { return null } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx index bac35037a..5443d1940 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/variable.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { PLCVariable } from '../../../../../middleware/shared/ports/types' import { useDebugger } from '../../../../../middleware/shared/providers' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' +import { useDebugValue, useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { forceDebugVariable, releaseDebugVariable } from '../../../../services/debug-force-variable' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { resolveArrayVariableByName } from '../../../../utils/PLC/array-variable-utils' @@ -44,11 +46,13 @@ const VariableElement = (block: VariableProps) => { project: { data: { pous, dataTypes }, }, - workspace: { isDebuggerVisible, debugVariableValues, debugVariableIndexes, debugForcedVariables }, - workspaceActions: { setDebugForcedVariables }, } = useOpenPLCStore() const debugger_ = useDebugger() + const isDebuggerVisible = useIsDebuggerVisible() + const getCompositeKey = useDebugCompositeKey() + const compositeKey = getCompositeKey(data.variable.name) + const { value: debugValue, isForced, forcedValue: forcedBoolValue, debugIndex } = useDebugValue(compositeKey) const inputVariableRef = useRef< HTMLTextAreaElement & { @@ -78,8 +82,6 @@ const VariableElement = (block: VariableProps) => { const [forceValueModalOpen, setForceValueModalOpen] = useState(false) const [forceValue, setForceValue] = useState('') - const getCompositeKey = useDebugCompositeKey() - /** * Get the connection type */ @@ -289,92 +291,35 @@ const VariableElement = (block: VariableProps) => { return variable?.type.value } - const getDebuggerColor = (): string | undefined => { - if (!isDebuggerVisible || !data.variable.name || !isAVariable) { - return undefined - } - - const variableType = getVariableType() - if (!variableType || variableType.toUpperCase() !== 'BOOL') { - return undefined - } - - const compositeKey = getCompositeKey(data.variable.name) - - if (debugForcedVariables.has(compositeKey)) { - const forcedVal = debugForcedVariables.get(compositeKey) - return forcedVal ? '#80C000' : '#4080FF' - } - - const value = debugVariableValues.get(compositeKey) - if (value === undefined) { - return undefined - } - - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' + const debuggerColor = (() => { + if (!isDebuggerVisible || !data.variable.name || !isAVariable) return undefined + const vType = getVariableType() + if (!vType || vType.toUpperCase() !== 'BOOL') return undefined + if (isForced) return forcedBoolValue ? '#80C000' : '#4080FF' + if (debugValue === undefined) return undefined + const isTrue = debugValue === '1' || debugValue.toUpperCase() === 'TRUE' return isTrue ? '#00FF00' : '#0464FB' - } - - const debuggerColor = getDebuggerColor() + })() const handleForceTrue = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - - if (variableIndex === undefined) return - - const result = await debugger_.setVariable(variableIndex, true, new Uint8Array([1])) - if (result.success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, true) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([1]), true) } const handleForceFalse = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - - if (variableIndex === undefined) return - - const result = await debugger_.setVariable(variableIndex, true, new Uint8Array([0])) - if (result.success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, false) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([0]), false) } const handleReleaseForce = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - - if (variableIndex === undefined) return - - const result = await debugger_.setVariable(variableIndex, false) - if (result.success) { - const newForced = new Map(debugForcedVariables) - newForced.delete(compositeKey) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await releaseDebugVariable(debugger_, compositeKey, debugIndex) } const handleForceValue = (e: React.MouseEvent) => { @@ -391,10 +336,7 @@ const VariableElement = (block: VariableProps) => { return } - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - - if (variableIndex === undefined) { + if (debugIndex === undefined) { setForceValueModalOpen(false) setForceValue('') return @@ -450,13 +392,7 @@ const VariableElement = (block: VariableProps) => { forcedValueForState = parsedIntValue >= BigInt(0) } - const result = await debugger_.setVariable(variableIndex, true, valueBuffer) - - if (result.success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, forcedValueForState) - setDebugForcedVariables(newForced) - } + await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, forcedValueForState) setForceValueModalOpen(false) setForceValue('') @@ -485,10 +421,6 @@ const VariableElement = (block: VariableProps) => { const variableType = getVariableType() const isBoolVariable = variableType?.toUpperCase() === 'BOOL' - const compositeKeyForForced = getCompositeKey(data.variable.name) - const isForced = debugForcedVariables.has(compositeKeyForForced) - const forcedValue_ = debugForcedVariables.get(compositeKeyForForced) - /** * Handle with the variable input onBlur event */ @@ -594,8 +526,8 @@ const VariableElement = (block: VariableProps) => { 'text-yellow-500': !isAVariable, 'text-red-500': inputError, 'font-bold': isForced, - 'text-[#80C000]': isForced && forcedValue_, - 'text-[#4080FF]': isForced && !forcedValue_, + 'text-[#80C000]': isForced && forcedBoolValue, + 'text-[#4080FF]': isForced && !forcedBoolValue, })} highlightClassName={cn('text-center placeholder:text-center text-xs leading-3', {})} scrollableIndicatorClassName={cn({ @@ -643,7 +575,7 @@ const VariableElement = (block: VariableProps) => { {isDebuggerVisible && isAVariable && ( diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index ac08764a7..66661153f 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' +import { useDebugValue, useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { forceDebugVariable, releaseDebugVariable } from '../../../../services/debug-force-variable' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { HighlightedTextArea } from '../../highlighted-textarea' @@ -24,11 +26,13 @@ export const Coil = (block: CoilProps) => { }, ladderFlows, ladderFlowActions: { updateNode }, - workspace: { isDebuggerVisible, debugVariableValues, debugVariableIndexes, debugForcedVariables }, - workspaceActions: { setDebugForcedVariables }, } = useOpenPLCStore() const debugger_ = useDebugger() + const isDebuggerVisible = useIsDebuggerVisible() + const getCompositeKey = useDebugCompositeKey() + const compositeKey = getCompositeKey(data.variable.name) + const { value: debugValue, isForced, forcedValue, debugIndex } = useDebugValue(compositeKey) const coil = DEFAULT_COIL_TYPES[data.variant] const [coilVariableValue, setCoilVariableValue] = useState(data.variable.name) @@ -50,8 +54,6 @@ export const Coil = (block: CoilProps) => { } >(null) - const getCompositeKey = useDebugCompositeKey() - const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') @@ -131,31 +133,16 @@ export const Coil = (block: CoilProps) => { setWrongVariable(false) }, [pous]) - const getDebuggerFillColor = (): string | undefined => { - if (!isDebuggerVisible || !data.variable.name || wrongVariable) { - return undefined - } - - const compositeKey = getCompositeKey(data.variable.name) - const value = debugVariableValues.get(compositeKey) - - if (value === undefined) { - return undefined - } + const debuggerFillColor = (() => { + if (!isDebuggerVisible || !data.variable.name || wrongVariable) return undefined + if (debugValue === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' + const isTrue = debugValue === '1' || debugValue.toUpperCase() === 'TRUE' const displayState = data.variant === 'negated' ? !isTrue : isTrue - const isForced = debugForcedVariables.has(compositeKey) - if (isForced) { - const forcedValue = debugForcedVariables.get(compositeKey) - return forcedValue ? '#80C000' : '#4080FF' - } - + if (isForced) return forcedValue ? '#80C000' : '#4080FF' return displayState ? '#00FF00' : '#0464FB' - } - - const debuggerFillColor = getDebuggerFillColor() + })() const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) @@ -164,57 +151,21 @@ export const Coil = (block: CoilProps) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, true, new Uint8Array([1])) - if (success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, true) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([1]), true) } const handleForceFalse = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, true, new Uint8Array([0])) - if (success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, false) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([0]), false) } const handleReleaseForce = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, false) - if (success) { - const newForced = new Map(debugForcedVariables) - newForced.delete(compositeKey) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await releaseDebugVariable(debugger_, compositeKey, debugIndex) } const handleClick = (e: React.MouseEvent) => { @@ -378,9 +329,6 @@ export const Coil = (block: CoilProps) => { {isDebuggerVisible && contextMenuPosition && (() => { - const compositeKey = getCompositeKey(data.variable.name) - const isForced = debugForcedVariables.has(compositeKey) - return ( diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index 24919d6a1..f9f67a074 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react' import { useDebugger } from '../../../../../middleware/shared/providers' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' +import { useDebugValue, useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { forceDebugVariable, releaseDebugVariable } from '../../../../services/debug-force-variable' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { HighlightedTextArea } from '../../highlighted-textarea' @@ -23,11 +25,13 @@ export const Contact = (block: ContactProps) => { }, ladderFlows, ladderFlowActions: { updateNode }, - workspace: { isDebuggerVisible, debugVariableValues, debugVariableIndexes, debugForcedVariables }, - workspaceActions: { setDebugForcedVariables }, } = useOpenPLCStore() const debugger_ = useDebugger() + const isDebuggerVisible = useIsDebuggerVisible() + const getCompositeKey = useDebugCompositeKey() + const compositeKey = getCompositeKey(data.variable.name) + const { value: debugValue, isForced, forcedValue, debugIndex } = useDebugValue(compositeKey) const contact = DEFAULT_CONTACT_TYPES[data.variant] const [contactVariableValue, setContactVariableValue] = useState(data.variable.name) @@ -49,8 +53,6 @@ export const Contact = (block: ContactProps) => { } >(null) - const getCompositeKey = useDebugCompositeKey() - const [openAutocomplete, setOpenAutocomplete] = useState(false) const [keyPressedAtTextarea, setKeyPressedAtTextarea] = useState('') @@ -129,31 +131,16 @@ export const Contact = (block: ContactProps) => { setWrongVariable(false) }, [pous]) - const getDebuggerStrokeColor = (): string | undefined => { - if (!isDebuggerVisible || !data.variable.name || wrongVariable) { - return undefined - } - - const compositeKey = getCompositeKey(data.variable.name) - const value = debugVariableValues.get(compositeKey) - - if (value === undefined) { - return undefined - } + const debuggerStrokeColor = (() => { + if (!isDebuggerVisible || !data.variable.name || wrongVariable) return undefined + if (debugValue === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' + const isTrue = debugValue === '1' || debugValue.toUpperCase() === 'TRUE' const displayState = data.variant === 'negated' ? !isTrue : isTrue - const isForced = debugForcedVariables.has(compositeKey) - if (isForced) { - const forcedValue = debugForcedVariables.get(compositeKey) - return forcedValue ? '#80C000' : '#4080FF' - } - + if (isForced) return forcedValue ? '#80C000' : '#4080FF' return displayState ? '#00FF00' : '#0464FB' - } - - const debuggerStrokeColor = getDebuggerStrokeColor() + })() const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) @@ -162,57 +149,21 @@ export const Contact = (block: ContactProps) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, true, new Uint8Array([1])) - if (success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, true) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([1]), true) } const handleForceFalse = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, true, new Uint8Array([0])) - if (success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, false) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([0]), false) } const handleReleaseForce = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, false) - if (success) { - const newForced = new Map(debugForcedVariables) - newForced.delete(compositeKey) - setDebugForcedVariables(newForced) - } + if (data.variable.name) await releaseDebugVariable(debugger_, compositeKey, debugIndex) } const handleClick = (e: React.MouseEvent) => { @@ -376,9 +327,6 @@ export const Contact = (block: ContactProps) => { {isDebuggerVisible && contextMenuPosition && (() => { - const compositeKey = getCompositeKey(data.variable.name) - const isForced = debugForcedVariables.has(compositeKey) - return ( diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx index 9eca153d2..b1f58741c 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react' import { PLCVariable } from '../../../../../middleware/shared/ports' import { useDebugger } from '../../../../../middleware/shared/providers' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' +import { useDebugValue, useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { forceDebugVariable, releaseDebugVariable } from '../../../../services/debug-force-variable' import { useOpenPLCStore } from '../../../../store' import { RungLadderState } from '../../../../store/slices/ladder' import { cn } from '../../../../utils/cn' @@ -36,11 +38,12 @@ const VariableElement = (block: VariableProps) => { }, ladderFlows, ladderFlowActions: { updateNode }, - workspace: { isDebuggerVisible, debugVariableIndexes, debugForcedVariables }, - workspaceActions: { setDebugForcedVariables }, } = useOpenPLCStore() const debugger_ = useDebugger() + const isDebuggerVisible = useIsDebuggerVisible() const getCompositeKey = useDebugCompositeKey() + const compositeKey = getCompositeKey(data.variable.name) + const { isForced, forcedValue: forcedBoolValue, debugIndex } = useDebugValue(compositeKey) const inputVariableRef = useRef< HTMLTextAreaElement & { @@ -254,57 +257,21 @@ const VariableElement = (block: VariableProps) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, true, new Uint8Array([1])) - if (success) { - const newForcedVariables = new Map(debugForcedVariables) - newForcedVariables.set(compositeKey, true) - setDebugForcedVariables(newForcedVariables) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([1]), true) } const handleForceFalse = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, true, new Uint8Array([0])) - if (success) { - const newForcedVariables = new Map(debugForcedVariables) - newForcedVariables.set(compositeKey, false) - setDebugForcedVariables(newForcedVariables) - } + if (data.variable.name) await forceDebugVariable(debugger_, compositeKey, debugIndex, new Uint8Array([0]), false) } const handleReleaseForce = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setIsContextMenuOpen(false) - - if (!data.variable.name) return - - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - if (variableIndex === undefined) return - - const success = await debugger_.setVariable(variableIndex, false) - if (success) { - const newForcedVariables = new Map(debugForcedVariables) - newForcedVariables.delete(compositeKey) - setDebugForcedVariables(newForcedVariables) - } + if (data.variable.name) await releaseDebugVariable(debugger_, compositeKey, debugIndex) } const handleForceValueOpen = (e: React.MouseEvent) => { @@ -321,10 +288,7 @@ const VariableElement = (block: VariableProps) => { return } - const compositeKey = getCompositeKey(data.variable.name) - const variableIndex = debugVariableIndexes.get(compositeKey) - - if (variableIndex === undefined) { + if (debugIndex === undefined) { setForceValueModalOpen(false) setForceValue('') return @@ -380,13 +344,7 @@ const VariableElement = (block: VariableProps) => { forcedValueForState = parsedIntValue >= BigInt(0) } - const success = await debugger_.setVariable(variableIndex, true, valueBuffer) - - if (success) { - const newForcedVariables = new Map(debugForcedVariables) - newForcedVariables.set(compositeKey, forcedValueForState) - setDebugForcedVariables(newForcedVariables) - } + await forceDebugVariable(debugger_, compositeKey, debugIndex, valueBuffer, forcedValueForState) setForceValueModalOpen(false) setForceValue('') @@ -415,10 +373,6 @@ const VariableElement = (block: VariableProps) => { const variableType = getVariableType() const isBoolVariable = variableType?.toUpperCase() === 'BOOL' - const compositeKey = getCompositeKey(data.variable.name) - const isForced = debugForcedVariables.has(compositeKey) - const forcedValue2 = debugForcedVariables.get(compositeKey) - return ( <>
{ 'text-left placeholder:text-left': data.variant === 'output', 'text-right placeholder:text-right': data.variant === 'input', 'font-bold': isForced, - 'text-[#80C000]': isForced && forcedValue2, - 'text-[#4080FF]': isForced && !forcedValue2, + 'text-[#80C000]': isForced && forcedBoolValue, + 'text-[#4080FF]': isForced && !forcedBoolValue, })} highlightClassName={cn('text-center placeholder:text-center text-xs leading-3', { 'text-left placeholder:text-left': data.variant === 'output', diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index 423cad785..f6f10f8e9 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { baseTypeSchema } from '../../../../../../middleware/shared/ports/plc-schemas' import type { PLCPou } from '../../../../../../middleware/shared/ports/types' import { useAI, useCapabilities, useProject } from '../../../../../../middleware/shared/providers' +import { useDebugBoolValuesMap, useDebugNonBoolValuesMap } from '../../../../../hooks/use-debug-value' import { executeSaveActiveFile, executeSaveProject } from '../../../../../services/save-actions' import { openPLCStoreBase, useOpenPLCStore } from '../../../../../store' import { getExtensionFromLanguage, getFolderFromPouType } from '../../../../../utils/PLC/pou-file-extensions' @@ -139,7 +140,6 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType { const keys: string[] = [] - for (const key of debugVariableValues.keys()) keys.push(key) + for (const key of debugBoolValues.keys()) keys.push(key) + for (const key of debugNonBoolValues.keys()) keys.push(key) return keys.sort().join('\0') - }, [debugVariableValues]) + }, [debugBoolValues, debugNonBoolValues]) const debugVarPositions = useMemo(() => { if (!isDebuggerVisible || !editorRef.current || !monacoRef.current || (language !== 'st' && language !== 'il')) @@ -354,7 +357,10 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType collection.clear() - }, [debugVarPositions, debugVariableValues]) + }, [debugVarPositions, debugBoolValues, debugNonBoolValues]) // ----------------------------------------------------------------------- // Completion callbacks diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx index cdac66b73..ab8629340 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx +++ b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx @@ -16,6 +16,11 @@ import { debounce, isEqual } from 'lodash' import { DragEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' +import { + useDebugBoolValuesMap, + useDebugForcedVariablesMap, + useIsDebuggerVisible, +} from '../../../../hooks/use-debug-value' import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot' import { openPLCStoreBase, useOpenPLCStore } from '../../../../store' import type { FBDRungState } from '../../../../store/slices/fbd' @@ -49,8 +54,10 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } projectActions: { deleteVariable }, modals, modalActions: { closeModal, openModal }, - workspace: { isDebuggerVisible, debugVariableValues, debugForcedVariables }, } = useOpenPLCStore() + const isDebuggerVisible = useIsDebuggerVisible() + const debugVariableValues = useDebugBoolValuesMap() + const debugForcedVariables = useDebugForcedVariablesMap() const { captureAndPush } = usePouSnapshot() const { pous } = project.data diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index 662b43c90..ba0577350 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -5,6 +5,7 @@ import { DragEventHandler, MouseEvent, useCallback, useEffect, useMemo, useRef, import type { PLCVariable } from '../../../../../../middleware/shared/ports/types' import { useDebugCompositeKey } from '../../../../../hooks/use-debug-composite-key' +import { useDebugBoolValuesMap, useIsDebuggerVisible } from '../../../../../hooks/use-debug-value' import { usePouSnapshot } from '../../../../../hooks/use-pou-snapshot' import { useOpenPLCStore } from '../../../../../store' import type { RungLadderState } from '../../../../../store/slices/ladder' @@ -80,8 +81,9 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi modalActions: { openModal }, searchQuery, searchActions: { setSearchNodePosition }, - workspace: { isDebuggerVisible, debugVariableValues }, } = useOpenPLCStore() + const isDebuggerVisible = useIsDebuggerVisible() + const debugVariableValues = useDebugBoolValuesMap() const { captureAndPush } = usePouSnapshot() const { pous } = project.data diff --git a/src/frontend/components/_molecules/variables-panel/index.tsx b/src/frontend/components/_molecules/variables-panel/index.tsx index f9f5bccd1..180781057 100644 --- a/src/frontend/components/_molecules/variables-panel/index.tsx +++ b/src/frontend/components/_molecules/variables-panel/index.tsx @@ -30,7 +30,8 @@ type VariablePanelProps = { variableTree?: Map graphList?: string[] setGraphList: React.Dispatch> - debugVariableValues?: Map + debugBoolValues?: Map + debugNonBoolValues?: Map debugVariableIndexes?: Map debugForcedVariables?: Map debugExpandedNodes?: Map @@ -101,7 +102,8 @@ const VariablesPanel = ({ variableTree, setGraphList, graphList, - debugVariableValues, + debugBoolValues, + debugNonBoolValues, debugVariableIndexes, debugForcedVariables, debugExpandedNodes, @@ -126,7 +128,7 @@ const VariablesPanel = ({ } | null>(null) const getValue = (compositeKey: string): string | undefined => { - return debugVariableValues?.get(compositeKey) + return debugBoolValues?.get(compositeKey) ?? debugNonBoolValues?.get(compositeKey) } const toggleGraphVisibility = (variableName: string) => { diff --git a/src/frontend/components/_organisms/debugger/index.tsx b/src/frontend/components/_organisms/debugger/index.tsx index 5814e90da..dd0b09c51 100644 --- a/src/frontend/components/_organisms/debugger/index.tsx +++ b/src/frontend/components/_organisms/debugger/index.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ArrowIcon } from '../../../assets/icons/interface/Arrow' import { PauseIcon } from '../../../assets/icons/interface/Pause' import { PlayIcon } from '../../../assets/icons/interface/Play' +import { useDebugBoolValuesMap, useDebugNonBoolValuesMap } from '../../../hooks/use-debug-value' import { useOpenPLCStore } from '../../../store' import { Button } from '../../_atoms/buttons/default' import { LineChart } from '../../_molecules/charts/line-chart' @@ -26,9 +27,10 @@ const Debugger = ({ graphList }: DebuggerData) => { const sessionStartRef = useRef(Date.now()) const [elapsedMs, setElapsedMs] = useState(0) - const { - workspace: { debugVariableValues, debugTick, debugDataStale }, - } = useOpenPLCStore() + const debugTick = useOpenPLCStore(useCallback((s) => s.workspace.debugTick, [])) + const debugDataStale = useOpenPLCStore(useCallback((s) => s.workspace.debugDataStale, [])) + const debugBoolValues = useDebugBoolValuesMap() + const debugNonBoolValues = useDebugNonBoolValuesMap() // Track elapsed time with a 1-second interval useEffect(() => { @@ -61,7 +63,7 @@ const Debugger = ({ graphList }: DebuggerData) => { }, [graphList]) useEffect(() => { - if (isPaused) return + if (isPaused || graphList.length === 0) return const now = Date.now() // Set start time on first data if (startTimeRef.current === null) { @@ -69,7 +71,9 @@ const Debugger = ({ graphList }: DebuggerData) => { } const set = historiesRef.current for (const [, entry] of set) { - const raw = entry.compositeKey ? debugVariableValues.get(entry.compositeKey) : undefined + const raw = entry.compositeKey + ? (debugBoolValues.get(entry.compositeKey) ?? debugNonBoolValues.get(entry.compositeKey)) + : undefined if (raw === undefined) continue let y: number | null = null const rawStr = String(raw).toUpperCase() @@ -96,7 +100,7 @@ const Debugger = ({ graphList }: DebuggerData) => { } } setRenderTrigger((prev) => prev + 1) - }, [debugVariableValues, isPaused]) + }, [debugBoolValues, debugNonBoolValues, isPaused, graphList]) const renderSeries = useMemo(() => { const now = Date.now() diff --git a/src/frontend/components/_organisms/workspace-activity-bar/index.tsx b/src/frontend/components/_organisms/workspace-activity-bar/index.tsx index edd83abbc..33354b549 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/index.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/index.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react' + import { useOpenPLCStore } from '../../../store' import { DividerActivityBar } from '../../_atoms/workspace-activity-bar/divider' import { ExitButton } from '../../_molecules/workspace-activity-bar/default/exit' @@ -15,10 +17,8 @@ type ActivityBarProps = { } export const WorkspaceActivityBar = ({ defaultActivityBar }: ActivityBarProps) => { - const { - editor, - sharedWorkspaceActions: { closeProject }, - } = useOpenPLCStore() + const editor = useOpenPLCStore(useCallback((s) => s.editor, [])) + const { closeProject } = useOpenPLCStore(useCallback((s) => s.sharedWorkspaceActions, [])) const isFBDEditor = editor?.type === 'plc-graphical' && editor?.meta.language === 'fbd' const isLadderEditor = editor?.type === 'plc-graphical' && editor?.meta.language === 'ld' diff --git a/src/frontend/components/_templates/[workspace]/main-content.tsx b/src/frontend/components/_templates/[workspace]/main-content.tsx index 293aa90d3..b29a1778f 100644 --- a/src/frontend/components/_templates/[workspace]/main-content.tsx +++ b/src/frontend/components/_templates/[workspace]/main-content.tsx @@ -1,15 +1,11 @@ -import { ComponentPropsWithoutRef } from 'react' +import { ComponentPropsWithoutRef, useCallback } from 'react' import { useOpenPLCStore } from '../../../store' import { cn } from '../../../utils/cn' type IWorkspaceMainContentProps = ComponentPropsWithoutRef<'div'> const WorkspaceMainContent = (props: IWorkspaceMainContentProps) => { - const { - workspace: { - systemConfigs: { OS }, - }, - } = useOpenPLCStore() + const OS = useOpenPLCStore(useCallback((s) => s.workspace.systemConfigs.OS, [])) const { children, ...res } = props return ( diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index a4d5d1ace..1760a9602 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -1,4 +1,4 @@ -import { ComponentPropsWithoutRef, ReactNode, useEffect, useState } from 'react' +import { ComponentPropsWithoutRef, ReactNode, useCallback, useEffect, useState } from 'react' import { useProject, useSystem } from '../../../middleware/shared/providers' import { useOpenPLCStore } from '../../store' @@ -25,13 +25,9 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { const system = useSystem() const projectPort = useProject() const [showComponent, setShowComponent] = useState(true) - const { - modals, - workspace: { - systemConfigs: { OS }, - }, - workspaceActions: { setSystemConfigs, setRecent }, - } = useOpenPLCStore() + const modals = useOpenPLCStore(useCallback((s) => s.modals, [])) + const OS = useOpenPLCStore(useCallback((s) => s.workspace.systemConfigs.OS, [])) + const { setSystemConfigs, setRecent } = useOpenPLCStore(useCallback((s) => s.workspaceActions, [])) // Theme initialization - applies dark class before DisplayMenu mounts useEffect(() => { diff --git a/src/frontend/hooks/use-debug-composite-key.ts b/src/frontend/hooks/use-debug-composite-key.ts index 1fb937861..460820a0e 100644 --- a/src/frontend/hooks/use-debug-composite-key.ts +++ b/src/frontend/hooks/use-debug-composite-key.ts @@ -6,35 +6,36 @@ import { useOpenPLCStore } from '../store' * Hook that returns a memoized function to build composite keys for debug variable lookups. * Handles both program POUs (simple `pouName:variableName` format) and function block POUs * (resolved to `programName:fbVariableName.variableName` using the selected FB instance context). + * + * Uses targeted Zustand selectors so it only re-renders when the active editor, + * the current POU's type, or the selected FB instance changes — not on every + * unrelated project or workspace mutation. */ export const useDebugCompositeKey = () => { - const { - editor, - project: { - data: { pous }, - }, - workspace: { fbSelectedInstance, fbDebugInstances }, - } = useOpenPLCStore() - - const pouRef = pous.find((p) => p.name === editor.meta.name) + const editorName = useOpenPLCStore(useCallback((s) => s.editor.meta.name, [])) + const pouType = useOpenPLCStore( + useCallback((s) => s.project.data.pous.find((p) => p.name === s.editor.meta.name)?.pouType, []), + ) + const fbSelectedInstance = useOpenPLCStore(useCallback((s) => s.workspace.fbSelectedInstance, [])) + const fbDebugInstances = useOpenPLCStore(useCallback((s) => s.workspace.fbDebugInstances, [])) const fbInstanceContext = useMemo(() => { - if (!pouRef || pouRef.pouType !== 'function-block') return null - const fbTypeKey = pouRef.name.toUpperCase() + if (pouType !== 'function-block') return null + const fbTypeKey = editorName.toUpperCase() const selectedKey = fbSelectedInstance.get(fbTypeKey) if (!selectedKey) return null const instances = fbDebugInstances.get(fbTypeKey) || [] return instances.find((inst) => inst.key === selectedKey) || null - }, [pouRef, fbSelectedInstance, fbDebugInstances]) + }, [pouType, editorName, fbSelectedInstance, fbDebugInstances]) const getCompositeKey = useCallback( (variableName: string): string => { if (fbInstanceContext) { return `${fbInstanceContext.programName}:${fbInstanceContext.fbVariableName}.${variableName}` } - return `${editor.meta.name}:${variableName}` + return `${editorName}:${variableName}` }, - [fbInstanceContext, editor.meta.name], + [fbInstanceContext, editorName], ) return getCompositeKey diff --git a/src/frontend/hooks/use-debug-value.ts b/src/frontend/hooks/use-debug-value.ts new file mode 100644 index 000000000..3518bb0d0 --- /dev/null +++ b/src/frontend/hooks/use-debug-value.ts @@ -0,0 +1,100 @@ +import { useCallback } from 'react' +import { shallow } from 'zustand/shallow' + +import { useOpenPLCStore } from '../store' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DebugVariableState { + value: string | undefined + isForced: boolean + forcedValue: boolean | undefined + debugIndex: number | undefined +} + +// --------------------------------------------------------------------------- +// Scalar selectors (no key dependency) +// --------------------------------------------------------------------------- + +/** Subscribe only to the global debugger-visible flag. */ +export function useIsDebuggerVisible(): boolean { + return useOpenPLCStore(useCallback((s) => s.workspace.isDebuggerVisible, [])) +} + +// --------------------------------------------------------------------------- +// Per-key selectors (re-render only when THIS key's value changes) +// --------------------------------------------------------------------------- + +/** + * Polled debug value for a single composite key. + * Checks both BOOL and non-BOOL maps — Zustand's Object.is comparison on the + * returned string means unchanged keys skip re-render even when a Map reference changes. + */ +export function useDebugVariableValue(compositeKey: string): string | undefined { + return useOpenPLCStore( + useCallback( + (s) => s.workspace.debugBoolValues.get(compositeKey) ?? s.workspace.debugNonBoolValues.get(compositeKey), + [compositeKey], + ), + ) +} + +/** Whether a variable is currently forced. */ +export function useIsVariableForced(compositeKey: string): boolean { + return useOpenPLCStore(useCallback((s) => s.workspace.debugForcedVariables.has(compositeKey), [compositeKey])) +} + +/** The forced boolean value (true = forced high, false = forced low). */ +export function useForcedValue(compositeKey: string): boolean | undefined { + return useOpenPLCStore(useCallback((s) => s.workspace.debugForcedVariables.get(compositeKey), [compositeKey])) +} + +/** The protocol index for force/release transport. */ +export function useDebugVariableIndex(compositeKey: string): number | undefined { + return useOpenPLCStore(useCallback((s) => s.workspace.debugVariableIndexes.get(compositeKey), [compositeKey])) +} + +// --------------------------------------------------------------------------- +// Composite per-key selector (single subscription, shallow equality) +// --------------------------------------------------------------------------- + +/** + * All debug state for a single variable in one subscription. + * Uses shallow equality so the component only re-renders when one of the + * four values actually changes — not on every polling cycle. + */ +export function useDebugValue(compositeKey: string): DebugVariableState { + return useOpenPLCStore( + useCallback( + (s) => ({ + value: s.workspace.debugBoolValues.get(compositeKey) ?? s.workspace.debugNonBoolValues.get(compositeKey), + isForced: s.workspace.debugForcedVariables.has(compositeKey), + forcedValue: s.workspace.debugForcedVariables.get(compositeKey), + debugIndex: s.workspace.debugVariableIndexes.get(compositeKey), + }), + [compositeKey], + ), + shallow, + ) +} + +// --------------------------------------------------------------------------- +// Map-level selectors (for containers that iterate all values) +// --------------------------------------------------------------------------- + +/** BOOL values Map — for canvas containers that only need BOOL for edge coloring. */ +export function useDebugBoolValuesMap(): Map { + return useOpenPLCStore(useCallback((s) => s.workspace.debugBoolValues, [])) +} + +/** Non-BOOL values Map — for display panels that need non-BOOL values (charts, monaco, sidebar). */ +export function useDebugNonBoolValuesMap(): Map { + return useOpenPLCStore(useCallback((s) => s.workspace.debugNonBoolValues, [])) +} + +/** Full forced Map — use only in container components that need to iterate all keys. */ +export function useDebugForcedVariablesMap(): Map { + return useOpenPLCStore(useCallback((s) => s.workspace.debugForcedVariables, [])) +} diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index bdb277bad..5a249218e 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -74,6 +74,15 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const { workspaceActions, consoleActions } = useOpenPLCStore() + // Targeted selectors for active-index cache invalidation. + // These only change on user interaction (not every poll cycle). + const pous = useOpenPLCStore(useCallback((s) => s.project.data.pous, [])) + const editorName = useOpenPLCStore(useCallback((s) => s.editor.meta.name, [])) + const debugForcedVariables = useOpenPLCStore(useCallback((s) => s.workspace.debugForcedVariables, [])) + const debugExpandedNodes = useOpenPLCStore(useCallback((s) => s.workspace.debugExpandedNodes, [])) + const debugGraphList = useOpenPLCStore(useCallback((s) => s.workspace.debugGraphList, [])) + const fbSelectedInstance = useOpenPLCStore(useCallback((s) => s.workspace.fbSelectedInstance, [])) + const pollingIntervalRef = useRef(null) const staleCheckRef = useRef(null) const lastResponseTimestampRef = useRef(0) @@ -86,6 +95,9 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void // Full leaf index→metadata map — computed once when debugger starts. const allLeavesRef = useRef | null>(null) + // Cached active indexes — rebuilt only when invalidation triggers change. + const activeIndexesRef = useRef(null) + // Cache for diagram/source-visible variable scan results. // Keyed by {pouName, language, fbContextKey}. Invalidated on POU/FB switch. const visibleVarsCacheRef = useRef<{ @@ -95,6 +107,12 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void keys: Set } | null>(null) + // Invalidate active index cache when any input changes + useEffect(() => { + activeIndexesRef.current = null + visibleVarsCacheRef.current = null + }, [pous, editorName, debugForcedVariables, debugExpandedNodes, debugGraphList, fbSelectedInstance]) + /** Build the full leaf metadata map from all debug trees (once per session). */ const getAllLeaves = useCallback(() => { if (allLeavesRef.current) return allLeavesRef.current @@ -122,15 +140,17 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const allLeaves = getAllLeaves() if (allLeaves.size === 0) return - // Build the filtered set of indexes to poll this tick - const state = openPLCStoreBase.getState() - const { activeIndexes, cacheResult } = buildActiveIndexSet(state, allLeaves, visibleVarsCacheRef.current) - - // Update the diagram/source cache if it changed - if (cacheResult !== visibleVarsCacheRef.current) { - visibleVarsCacheRef.current = cacheResult + // Use cached active indexes — only rebuild when invalidation triggers change + if (!activeIndexesRef.current) { + const state = openPLCStoreBase.getState() + const { activeIndexes, cacheResult } = buildActiveIndexSet(state, allLeaves, visibleVarsCacheRef.current) + if (cacheResult !== visibleVarsCacheRef.current) { + visibleVarsCacheRef.current = cacheResult + } + activeIndexesRef.current = activeIndexes } + const activeIndexes = activeIndexesRef.current if (activeIndexes.length === 0) return let currentBatchSize = batchSizeRef.current @@ -166,14 +186,18 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void // Update stale data tracking lastResponseTimestampRef.current = Date.now() - workspaceActions.setDebugDataStale(false) + if (openPLCStoreBase.getState().workspace.debugDataStale) { + workspaceActions.setDebugDataStale(false) + } // Parse response buffer → variable values let itemsProcessed = 0 if (result.data && result.data.length > 0) { const responseBuffer = new Uint8Array(result.data) - const newValues = new Map() + const { debugBoolValues: currentBool, debugNonBoolValues: currentNonBool } = openPLCStoreBase.getState().workspace + const changedBool = new Map() + const changedNonBool = new Map() let bufferOffset = 0 for (const index of batch) { @@ -183,12 +207,17 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const typeSize = getTypeSizeByName(meta.type) if (bufferOffset + typeSize > responseBuffer.length) break + const isBool = meta.type === 'BOOL' + try { const { value, bytesRead } = parseValueByTypeName(responseBuffer, bufferOffset, meta.type) - newValues.set(meta.compositeKey, value) + const current = isBool ? currentBool : currentNonBool + if (current.get(meta.compositeKey) !== value) { + ;(isBool ? changedBool : changedNonBool).set(meta.compositeKey, value) + } bufferOffset += bytesRead } catch { - newValues.set(meta.compositeKey, 'ERR') + ;(isBool ? changedBool : changedNonBool).set(meta.compositeKey, 'ERR') bufferOffset += typeSize } @@ -198,8 +227,9 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void if (result.lastIndex !== undefined && index >= result.lastIndex) break } - // Merge new values into the store - workspaceActions.setDebugVariableValues(newValues) + // Only write to store when values actually changed + if (changedBool.size > 0) workspaceActions.setDebugBoolValues(changedBool) + if (changedNonBool.size > 0) workspaceActions.setDebugNonBoolValues(changedNonBool) } // Advance offset for next poll cycle (wraps around) @@ -226,6 +256,7 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void batchSizeRef.current = isSimulatorBoard ? RTU_BATCH_SIZE : DEFAULT_BATCH_SIZE batchOffsetRef.current = 0 lastResponseTimestampRef.current = 0 + activeIndexesRef.current = null visibleVarsCacheRef.current = null const pollIntervalMs = isSimulatorBoard ? SIMULATOR_POLL_INTERVAL_MS : DEFAULT_POLL_INTERVAL_MS diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 0ae52fd0d..9718eabe3 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -1,6 +1,7 @@ import * as Tabs from '@radix-ui/react-tabs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ImperativePanelHandle } from 'react-resizable-panels' +import { shallow } from 'zustand/shallow' import { useCapabilities, useChatPanel, useDebugger, useDevice } from '../../middleware/shared/providers' import { ExitIcon } from '../assets/icons/interface/Exit' @@ -28,7 +29,14 @@ import { VariablesEditor } from '../components/_organisms/variables-editor' import { WorkspaceActivityBar } from '../components/_organisms/workspace-activity-bar' import { WorkspaceMainContent } from '../components/_templates/[workspace]/main-content' import { WorkspaceSideContent } from '../components/_templates/[workspace]/side-content' +import { + useDebugBoolValuesMap, + useDebugForcedVariablesMap, + useDebugNonBoolValuesMap, + useIsDebuggerVisible, +} from '../hooks/use-debug-value' import { useRuntimePolling } from '../hooks/use-runtime-polling' +import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' import { useOpenPLCStore } from '../store' import { cn } from '../utils/cn' @@ -38,36 +46,66 @@ const WorkspaceScreen = () => { const debuggerPort = useDebugger() const device = useDevice() + // STABLE: action references (never change) + const { toggleCollapse, clearPlcLogs, toggleDebugExpandedNode, setDebugGraphList } = useOpenPLCStore( + useCallback((s) => s.workspaceActions, []), + ) + const { setAvailableOptions } = useOpenPLCStore(useCallback((s) => s.deviceActions, [])) + + // RARE: UI state (changes on user interaction, not during debug polling) + const tabs = useOpenPLCStore(useCallback((s) => s.tabs, [])) + const editor = useOpenPLCStore(useCallback((s) => s.editor, [])) + const searchResults = useOpenPLCStore(useCallback((s) => s.searchResults, [])) + const pous = useOpenPLCStore(useCallback((s) => s.project.data.pous, [])) + + // RARE: workspace UI + debug session state (grouped with shallow) const { - tabs, - workspace: { - isCollapsed, - isPlcLogsVisible, - plcLogs, - isDebuggerVisible, - debugVariableValues, - debugVariableTree, - debugVariableIndexes, - debugForcedVariables, - debugExpandedNodes, - fbSelectedInstance, - fbDebugInstances, - }, - editor, - workspaceActions: { - toggleCollapse, - clearPlcLogs, - setDebugForcedVariables, - toggleDebugExpandedNode, - setDebugGraphList, - }, - deviceActions: { setAvailableOptions }, - searchResults, - ai: { isChatOpen, isEnabled: isAIEnabled, hasConsented: hasAIConsented }, - project: { - data: { pous }, - }, - } = useOpenPLCStore() + isCollapsed, + isPlcLogsVisible, + plcLogs, + debugVariableTree, + debugVariableIndexes, + debugExpandedNodes, + fbSelectedInstance, + fbDebugInstances, + } = useOpenPLCStore( + useCallback( + (s) => ({ + isCollapsed: s.workspace.isCollapsed, + isPlcLogsVisible: s.workspace.isPlcLogsVisible, + plcLogs: s.workspace.plcLogs, + debugVariableTree: s.workspace.debugVariableTree, + debugVariableIndexes: s.workspace.debugVariableIndexes, + debugExpandedNodes: s.workspace.debugExpandedNodes, + fbSelectedInstance: s.workspace.fbSelectedInstance, + fbDebugInstances: s.workspace.fbDebugInstances, + }), + [], + ), + shallow, + ) + + // RARE: AI state (grouped with shallow) + const { + isChatOpen, + isEnabled: isAIEnabled, + hasConsented: hasAIConsented, + } = useOpenPLCStore( + useCallback( + (s) => ({ + isChatOpen: s.ai.isChatOpen, + isEnabled: s.ai.isEnabled, + hasConsented: s.ai.hasConsented, + }), + [], + ), + shallow, + ) + + const isDebuggerVisible = useIsDebuggerVisible() + const debugBoolValues = useDebugBoolValuesMap() + const debugNonBoolValues = useDebugNonBoolValuesMap() + const debugForcedVariables = useDebugForcedVariablesMap() // Start global runtime polling for status and logs useRuntimePolling() @@ -112,7 +150,7 @@ const WorkspaceScreen = () => { displayName = v.name } - const variableValue = debugVariableValues.get(compositeKey) + const variableValue = debugBoolValues.get(compositeKey) ?? debugNonBoolValues.get(compositeKey) const displayValue = variableValue !== undefined ? variableValue : '-' return { @@ -124,7 +162,7 @@ const WorkspaceScreen = () => { } }) }), - [pous, debugVariableValues, fbSelectedInstance, fbDebugInstances], + [pous, debugBoolValues, debugNonBoolValues, fbSelectedInstance, fbDebugInstances], ) // Deduplicate names with POU prefix when conflicts exist @@ -169,25 +207,13 @@ const WorkspaceScreen = () => { if (!debuggerPort.isConnected()) return if (value === undefined && valueBuffer === undefined) { - // Release force - const result = await debuggerPort.setVariable(variableIndex, false) - if (result.success) { - const newForced = new Map(debugForcedVariables) - newForced.delete(compositeKey) - setDebugForcedVariables(newForced) - } + await releaseDebugVariable(debuggerPort, compositeKey, variableIndex) } else { - // Set force const buffer = valueBuffer ?? new Uint8Array([value ? 1 : 0]) - const result = await debuggerPort.setVariable(variableIndex, true, buffer) - if (result.success) { - const newForced = new Map(debugForcedVariables) - newForced.set(compositeKey, value ?? true) - setDebugForcedVariables(newForced) - } + await forceDebugVariable(debuggerPort, compositeKey, variableIndex, buffer, value ?? true) } }, - [debugVariableIndexes, debugForcedVariables, setDebugForcedVariables, debuggerPort], + [debugVariableIndexes, debuggerPort], ) const [graphList, _setGraphList] = useState([]) @@ -480,7 +506,8 @@ const WorkspaceScreen = () => { variableTree={filteredDebugVariableTree} graphList={graphList} setGraphList={setGraphList} - debugVariableValues={debugVariableValues} + debugBoolValues={debugBoolValues} + debugNonBoolValues={debugNonBoolValues} debugVariableIndexes={debugVariableIndexes} debugForcedVariables={debugForcedVariables} debugExpandedNodes={debugExpandedNodes} diff --git a/src/frontend/services/debug-force-variable.ts b/src/frontend/services/debug-force-variable.ts new file mode 100644 index 000000000..1eced0be4 --- /dev/null +++ b/src/frontend/services/debug-force-variable.ts @@ -0,0 +1,49 @@ +import type { DebuggerPort } from '../../middleware/shared/ports/debugger-port' +import { useOpenPLCStore } from '../store' + +/** + * Force a variable to a specific value via the debug protocol, then update + * the store's forced-variables Map on success. + * + * Uses `useOpenPLCStore.getState()` for imperative (non-hook) access so it is + * safe to call from async event handlers without stale-closure issues. + */ +export async function forceDebugVariable( + debuggerPort: DebuggerPort, + compositeKey: string, + debugIndex: number | undefined, + valueBuffer: Uint8Array, + forcedMapValue: boolean, +): Promise { + if (debugIndex === undefined) return false + + const result = await debuggerPort.setVariable(debugIndex, true, valueBuffer) + if (result.success) { + const state = useOpenPLCStore.getState() + const newForced = new Map(state.workspace.debugForcedVariables) + newForced.set(compositeKey, forcedMapValue) + state.workspaceActions.setDebugForcedVariables(newForced) + } + return result.success +} + +/** + * Release a forced variable via the debug protocol, then remove it from + * the store's forced-variables Map on success. + */ +export async function releaseDebugVariable( + debuggerPort: DebuggerPort, + compositeKey: string, + debugIndex: number | undefined, +): Promise { + if (debugIndex === undefined) return false + + const result = await debuggerPort.setVariable(debugIndex, false) + if (result.success) { + const state = useOpenPLCStore.getState() + const newForced = new Map(state.workspace.debugForcedVariables) + newForced.delete(compositeKey) + state.workspaceActions.setDebugForcedVariables(newForced) + } + return result.success +} diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index f25588d56..222983cfa 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -49,7 +49,8 @@ describe('createWorkspaceSlice', () => { expect(workspace.debuggerTargetIp).toBeNull() expect(workspace.debugCContent).toBeNull() expect(workspace.debugVariableIndexes).toEqual(new Map()) - expect(workspace.debugVariableValues).toEqual(new Map()) + expect(workspace.debugBoolValues).toEqual(new Map()) + expect(workspace.debugNonBoolValues).toEqual(new Map()) expect(workspace.debugForcedVariables).toEqual(new Map()) expect(workspace.debugTick).toBe(0) expect(workspace.debugVariableTree).toEqual(new Map()) @@ -309,15 +310,26 @@ describe('createWorkspaceSlice', () => { expect(store.getState().workspace.debugVariableIndexes).toEqual(indexes) }) - it('setDebugVariableValues merges values into existing map', () => { - const initial = new Map([['var1', 'val1']]) - store.getState().workspaceActions.setDebugVariableValues(initial) - expect(store.getState().workspace.debugVariableValues.get('var1')).toBe('val1') + it('setDebugBoolValues merges values into existing map', () => { + const initial = new Map([['var1', 'TRUE']]) + store.getState().workspaceActions.setDebugBoolValues(initial) + expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') - const update = new Map([['var2', 'val2']]) - store.getState().workspaceActions.setDebugVariableValues(update) - expect(store.getState().workspace.debugVariableValues.get('var1')).toBe('val1') - expect(store.getState().workspace.debugVariableValues.get('var2')).toBe('val2') + const update = new Map([['var2', 'FALSE']]) + store.getState().workspaceActions.setDebugBoolValues(update) + expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') + expect(store.getState().workspace.debugBoolValues.get('var2')).toBe('FALSE') + }) + + it('setDebugNonBoolValues merges values into existing map', () => { + const initial = new Map([['var1', '42']]) + store.getState().workspaceActions.setDebugNonBoolValues(initial) + expect(store.getState().workspace.debugNonBoolValues.get('var1')).toBe('42') + + const update = new Map([['var2', '3.14']]) + store.getState().workspaceActions.setDebugNonBoolValues(update) + expect(store.getState().workspace.debugNonBoolValues.get('var1')).toBe('42') + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('3.14') }) it('setDebugForcedVariables', () => { @@ -418,7 +430,7 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setDebuggerTargetIp('192.168.0.1') store.getState().workspaceActions.setDebugCContent('code') store.getState().workspaceActions.setDebugVariableIndexes(new Map([['x', 1]])) - store.getState().workspaceActions.setDebugVariableValues(new Map([['x', 'true']])) + store.getState().workspaceActions.setDebugBoolValues(new Map([['x', 'true']])) store.getState().workspaceActions.setDebugForcedVariables(new Map([['x', true]])) store.getState().workspaceActions.setDebugTick(100) store @@ -456,7 +468,8 @@ describe('createWorkspaceSlice', () => { expect(workspace.debuggerTargetIp).toBeNull() expect(workspace.debugCContent).toBeNull() expect(workspace.debugVariableIndexes.size).toBe(0) - expect(workspace.debugVariableValues.size).toBe(0) + expect(workspace.debugBoolValues.size).toBe(0) + expect(workspace.debugNonBoolValues.size).toBe(0) expect(workspace.debugForcedVariables.size).toBe(0) expect(workspace.debugTick).toBe(0) expect(workspace.debugVariableTree.size).toBe(0) @@ -502,7 +515,7 @@ describe('createWorkspaceSlice', () => { it('removeDebugVariable removes from all relevant maps', () => { const key = 'PROGRAM0::myVar' store.getState().workspaceActions.setDebugVariableIndexes(new Map([[key, 5]])) - store.getState().workspaceActions.setDebugVariableValues(new Map([[key, '42']])) + store.getState().workspaceActions.setDebugNonBoolValues(new Map([[key, '42']])) store.getState().workspaceActions.setDebugForcedVariables(new Map([[key, true]])) store .getState() @@ -515,7 +528,8 @@ describe('createWorkspaceSlice', () => { const { workspace } = store.getState() expect(workspace.debugVariableIndexes.has(key)).toBe(false) - expect(workspace.debugVariableValues.has(key)).toBe(false) + expect(workspace.debugBoolValues.has(key)).toBe(false) + expect(workspace.debugNonBoolValues.has(key)).toBe(false) expect(workspace.debugForcedVariables.has(key)).toBe(false) expect(workspace.debugVariableTree.has(key)).toBe(false) expect(workspace.debugExpandedNodes.has(key)).toBe(false) @@ -558,7 +572,8 @@ describe('createWorkspaceSlice', () => { expect(workspace.debuggerTargetIp).toBeNull() expect(workspace.debugCContent).toBeNull() expect(workspace.debugVariableIndexes.size).toBe(0) - expect(workspace.debugVariableValues.size).toBe(0) + expect(workspace.debugBoolValues.size).toBe(0) + expect(workspace.debugNonBoolValues.size).toBe(0) expect(workspace.debugForcedVariables.size).toBe(0) expect(workspace.debugTick).toBe(0) expect(workspace.debugVariableTree.size).toBe(0) diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 4e36d8bfa..6d517f5bd 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -41,7 +41,8 @@ const createWorkspaceSlice: StateCreator debuggerTargetIp: null, debugCContent: null, debugVariableIndexes: new Map(), - debugVariableValues: new Map(), + debugBoolValues: new Map(), + debugNonBoolValues: new Map(), debugForcedVariables: new Map(), debugTick: 0, debugVariableTree: new Map(), @@ -156,7 +157,8 @@ const createWorkspaceSlice: StateCreator workspace.debuggerTargetIp = null workspace.debugCContent = null workspace.debugVariableIndexes = new Map() - workspace.debugVariableValues = new Map() + workspace.debugBoolValues = new Map() + workspace.debugNonBoolValues = new Map() workspace.debugForcedVariables = new Map() workspace.debugTick = 0 workspace.debugVariableTree = new Map() @@ -274,11 +276,20 @@ const createWorkspaceSlice: StateCreator }), ) }, - setDebugVariableValues: (values: Map) => { + setDebugBoolValues: (values: Map) => { setState( produce(({ workspace }: WorkspaceSlice) => { for (const [key, val] of values) { - workspace.debugVariableValues.set(key, val) + workspace.debugBoolValues.set(key, val) + } + }), + ) + }, + setDebugNonBoolValues: (values: Map) => { + setState( + produce(({ workspace }: WorkspaceSlice) => { + for (const [key, val] of values) { + workspace.debugNonBoolValues.set(key, val) } }), ) @@ -368,7 +379,8 @@ const createWorkspaceSlice: StateCreator workspace.debuggerTargetIp = null workspace.debugCContent = null workspace.debugVariableIndexes = new Map() - workspace.debugVariableValues = new Map() + workspace.debugBoolValues = new Map() + workspace.debugNonBoolValues = new Map() workspace.debugForcedVariables = new Map() workspace.debugTick = 0 workspace.debugVariableTree = new Map() @@ -394,7 +406,8 @@ const createWorkspaceSlice: StateCreator setState( produce(({ workspace }: WorkspaceSlice) => { workspace.debugVariableIndexes.delete(compositeKey) - workspace.debugVariableValues.delete(compositeKey) + workspace.debugBoolValues.delete(compositeKey) + workspace.debugNonBoolValues.delete(compositeKey) workspace.debugForcedVariables.delete(compositeKey) workspace.debugVariableTree.delete(compositeKey) workspace.debugExpandedNodes.delete(compositeKey) diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index fd3c70f65..c6df2321b 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -78,7 +78,8 @@ export type WorkspaceState = { debuggerTargetIp: string | null debugCContent: string | null debugVariableIndexes: Map - debugVariableValues: Map + debugBoolValues: Map + debugNonBoolValues: Map debugForcedVariables: Map debugTick: number debugVariableTree: Map @@ -137,7 +138,8 @@ export type WorkspaceActions = { setDebuggerTargetIp: (targetIp: string | null) => void setDebugCContent: (content: string | null) => void setDebugVariableIndexes: (indexes: Map) => void - setDebugVariableValues: (values: Map) => void + setDebugBoolValues: (values: Map) => void + setDebugNonBoolValues: (values: Map) => void setDebugForcedVariables: (forced: Map) => void setDebugTick: (tick: number) => void setDebugVariableTree: (tree: Map) => void From 3e45653761e3ee5c0a5c85f1a595d2b8b7b4e414 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:19:22 -0300 Subject: [PATCH 02/19] feat: sync version control port and UI from web repo Sync byte-identical surfaces with web repo's version control feature. Add no-op editor version control adapter (guarded by hasVersionControl: false). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__architecture__/validate.ts | 8 +- .../_atoms/buttons/activity-bar/index.tsx | 4 +- .../_atoms/graphical-editor/diff/constants.ts | 38 + .../graphical-editor/diff/diff-wrapper.tsx | 50 ++ .../graphical-editor/diff/fbd-nodes.tsx | 82 ++ .../_atoms/graphical-editor/diff/index.ts | 47 ++ .../graphical-editor/diff/ladder-nodes.tsx | 141 ++++ .../graphical-editor/fbd/block-visual.tsx | 83 +++ .../graphical-editor/fbd/comment-visual.tsx | 47 ++ .../fbd/connection-visual.tsx | 74 ++ .../graphical-editor/fbd/variable-visual.tsx | 80 ++ .../graphical-editor/ladder/block-visual.tsx | 83 +++ .../graphical-editor/ladder/coil-visual.tsx | 52 ++ .../ladder/contact-visual.tsx | 52 ++ .../ladder/variable-visual.tsx | 56 ++ .../branches/branch-status-bar.tsx | 146 ++++ .../branches/branch-switcher-modal.tsx | 155 ++++ .../branches/create-branch-modal.tsx | 117 +++ .../branches/delete-branch-modal.tsx | 75 ++ .../_features/[workspace]/branches/index.ts | 5 + .../unsaved-changes-warning-modal.tsx | 53 ++ .../[workspace]/editor/graphical/index.tsx | 10 +- .../source-control/changes-section.tsx | 700 ++++++++++++++++++ .../source-control/commit-details.tsx | 189 +++++ .../source-control/commit-item.tsx | 43 ++ .../source-control/history-section.tsx | 105 +++ .../[workspace]/source-control/index.ts | 5 + .../modals/discard-confirmation-modal.tsx | 62 ++ .../modals/restore-confirmation-modal.tsx | 65 ++ .../source-control/source-control-panel.tsx | 87 +++ .../workspace-activity-bar/default/chat.tsx | 37 +- .../workspace-activity-bar/tooltip-button.tsx | 2 +- .../components/_organisms/explorer/index.tsx | 5 +- .../workspace-activity-bar/default.tsx | 4 +- .../workspace-activity-bar/index.tsx | 51 +- src/frontend/hooks/use-active-branch.ts | 65 ++ src/frontend/screens/workspace-screen.tsx | 627 +++++++++------- src/frontend/store/index.ts | 4 + src/frontend/store/slices/index.ts | 2 + src/frontend/store/slices/shared/slice.ts | 1 + src/frontend/store/slices/shared/types.ts | 2 + .../store/slices/version-control/index.ts | 2 + .../store/slices/version-control/slice.ts | 54 ++ .../store/slices/version-control/types.ts | 22 + .../editor/version-control-adapter.ts | 30 + src/middleware/editor-platform.ts | 2 + src/middleware/shared/ports/index.ts | 14 + .../shared/ports/platform-capabilities.ts | 5 + .../shared/ports/version-control-port.ts | 152 ++++ src/middleware/shared/providers/index.ts | 1 + .../shared/providers/platform-context.tsx | 4 + src/middleware/shared/providers/types.ts | 2 + 52 files changed, 3504 insertions(+), 298 deletions(-) create mode 100644 src/frontend/components/_atoms/graphical-editor/diff/constants.ts create mode 100644 src/frontend/components/_atoms/graphical-editor/diff/diff-wrapper.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/diff/fbd-nodes.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/diff/index.ts create mode 100644 src/frontend/components/_atoms/graphical-editor/diff/ladder-nodes.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/fbd/block-visual.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/fbd/comment-visual.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/fbd/connection-visual.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/fbd/variable-visual.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/ladder/block-visual.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/ladder/coil-visual.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/ladder/contact-visual.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/ladder/variable-visual.tsx create mode 100644 src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx create mode 100644 src/frontend/components/_features/[workspace]/branches/branch-switcher-modal.tsx create mode 100644 src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx create mode 100644 src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx create mode 100644 src/frontend/components/_features/[workspace]/branches/index.ts create mode 100644 src/frontend/components/_features/[workspace]/branches/unsaved-changes-warning-modal.tsx create mode 100644 src/frontend/components/_features/[workspace]/source-control/changes-section.tsx create mode 100644 src/frontend/components/_features/[workspace]/source-control/commit-details.tsx create mode 100644 src/frontend/components/_features/[workspace]/source-control/commit-item.tsx create mode 100644 src/frontend/components/_features/[workspace]/source-control/history-section.tsx create mode 100644 src/frontend/components/_features/[workspace]/source-control/index.ts create mode 100644 src/frontend/components/_features/[workspace]/source-control/modals/discard-confirmation-modal.tsx create mode 100644 src/frontend/components/_features/[workspace]/source-control/modals/restore-confirmation-modal.tsx create mode 100644 src/frontend/components/_features/[workspace]/source-control/source-control-panel.tsx create mode 100644 src/frontend/hooks/use-active-branch.ts create mode 100644 src/frontend/store/slices/version-control/index.ts create mode 100644 src/frontend/store/slices/version-control/slice.ts create mode 100644 src/frontend/store/slices/version-control/types.ts create mode 100644 src/middleware/adapters/editor/version-control-adapter.ts create mode 100644 src/middleware/shared/ports/version-control-port.ts diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 3c46834b0..ae9362d29 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -24,6 +24,7 @@ type LayerName = | 'provider' | 'adapters' | 'backend-shared' + | 'backend-web' | 'store' | 'services' | 'hooks' @@ -64,12 +65,16 @@ const LAYER_RULES: Record = { }, adapters: { name: 'Adapters (middleware/adapters/)', - allowedDeps: ['ports', 'provider', 'utils', 'backend-shared', 'store', 'assets'], + allowedDeps: ['ports', 'provider', 'utils', 'backend-shared', 'backend-web', 'store', 'assets'], }, 'backend-shared': { name: 'Backend Shared (backend/shared/)', allowedDeps: ['ports', 'utils', 'types'], }, + 'backend-web': { + name: 'Backend Web (backend/web/)', + allowedDeps: ['ports', 'utils', 'types', 'backend-shared'], + }, store: { name: 'Store (frontend/store/)', allowedDeps: ['ports', 'provider', 'store', 'utils', 'assets'], @@ -130,6 +135,7 @@ function getLayer(filePath: string): LayerName | null { // Backend layers if (rel.startsWith('backend/shared/')) return 'backend-shared' + if (rel.startsWith('backend/web/')) return 'backend-web' // Frontend layers if (rel.startsWith('frontend/store/')) return 'store' diff --git a/src/frontend/components/_atoms/buttons/activity-bar/index.tsx b/src/frontend/components/_atoms/buttons/activity-bar/index.tsx index 0e31231fd..c9d64844a 100644 --- a/src/frontend/components/_atoms/buttons/activity-bar/index.tsx +++ b/src/frontend/components/_atoms/buttons/activity-bar/index.tsx @@ -14,12 +14,12 @@ const ActivityBarButton = forwardRef +
+ + setShowSwitcher(false)} + onSelect={handleSelect} + onCreateNew={() => setShowCreate(true)} + onDelete={handleDelete} + /> + + setShowCreate(false)} /> + + { + setShowDelete(false) + setBranchToDelete(null) + }} + onDeleted={handleDeleted} + /> + + + + ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/branch-switcher-modal.tsx b/src/frontend/components/_features/[workspace]/branches/branch-switcher-modal.tsx new file mode 100644 index 000000000..ecc857b01 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/branch-switcher-modal.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from 'react' + +import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' +import { useVersionControl } from '../../../../../middleware/shared/providers' +import { cn } from '../../../../utils/cn' + +type BranchSwitcherModalProps = { + isOpen: boolean + projectId: string + currentBranchName: string + onClose: () => void + onSelect: (branch: Branch) => void + onCreateNew: () => void + onDelete: (branch: Branch) => void +} + +export function BranchSwitcherModal({ + isOpen, + projectId, + currentBranchName, + onClose, + onSelect, + onCreateNew, + onDelete, +}: BranchSwitcherModalProps) { + const versionControl = useVersionControl() + const [branches, setBranches] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [filter, setFilter] = useState('') + + useEffect(() => { + if (!isOpen || !versionControl) return + setFilter('') + setIsLoading(true) + versionControl + .listBranches(projectId) + .then(({ branches: b }) => setBranches(b)) + .catch(() => setBranches([])) + .finally(() => setIsLoading(false)) + }, [isOpen, projectId, versionControl]) + + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + const filtered = useMemo(() => { + if (!filter.trim()) return branches + const lower = filter.toLowerCase() + return branches.filter((b) => b.name.toLowerCase().includes(lower)) + }, [branches, filter]) + + if (!isOpen) return null + + return ( +
+
+
+ {/* Search */} +
+ setFilter(e.target.value)} + placeholder='Search branches...' + autoFocus + className='w-full rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 outline-none focus:border-blue-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500' + /> +
+ + {/* Branch list */} +
+ {isLoading && ( +

Loading...

+ )} + + {!isLoading && filtered.length === 0 && ( +

No branches found

+ )} + + {filtered.map((branch) => { + const isActive = branch.name === currentBranchName + return ( +
{ + onSelect(branch) + onClose() + }} + > + + + + {branch.name} + {isActive && ( + + + + )} + {branch.isDefault && ( + + default + + )} + {!branch.isDefault && ( + + )} +
+ ) + })} +
+ + {/* Create new branch */} +
+ +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx new file mode 100644 index 000000000..9df0f895f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react' + +import { useVersionControl } from '../../../../../middleware/shared/providers' + +type CreateBranchModalProps = { + isOpen: boolean + projectId: string + onClose: () => void + onCreated?: (name: string) => void +} + +export function CreateBranchModal({ isOpen, projectId, onClose, onCreated }: CreateBranchModalProps) { + const versionControl = useVersionControl() + const [name, setName] = useState('') + const [error, setError] = useState('') + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isPending) onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, isPending, onClose]) + + useEffect(() => { + if (isOpen) { + setName('') + setError('') + } + }, [isOpen]) + + if (!isOpen) return null + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = name.trim() + + if (!trimmed) { + setError('Branch name cannot be empty') + return + } + + if (/\s/.test(trimmed)) { + setError('Branch name cannot contain spaces') + return + } + + if (/[~^:?*[\]\\]/.test(trimmed)) { + setError('Branch name contains invalid characters') + return + } + + if (/(\.\.|\/\/|@\{|\.lock$|^\.|^\/|\/$)/.test(trimmed)) { + setError('Branch name has an invalid format') + return + } + + if (!versionControl) return + + setError('') + setIsPending(true) + versionControl + .createBranch(projectId, trimmed) + .then(() => { + onCreated?.(trimmed) + onClose() + }) + .catch((err: Error) => { + setError(err.message || 'Failed to create branch') + }) + .finally(() => setIsPending(false)) + } + + return ( +
+
+
+

Create Branch

+
+ setName(e.target.value)} + placeholder='feature/my-new-branch' + autoFocus + disabled={isPending} + className='w-full rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 outline-none focus:border-blue-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500' + /> + {error ? ( +

{error}

+ ) : ( +

No spaces allowed

+ )} +
+ + +
+
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx new file mode 100644 index 000000000..4b6b91e3c --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' + +import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' +import { useVersionControl } from '../../../../../middleware/shared/providers' +import { toast } from '../../../../utils/toast' + +type DeleteBranchModalProps = { + isOpen: boolean + projectId: string + branch: Branch | null + onClose: () => void + onDeleted?: () => void +} + +export function DeleteBranchModal({ isOpen, projectId, branch, onClose, onDeleted }: DeleteBranchModalProps) { + const versionControl = useVersionControl() + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isPending) onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, isPending, onClose]) + + if (!isOpen || !branch) return null + + const handleDelete = () => { + if (!versionControl) return + + setIsPending(true) + versionControl + .deleteBranch(projectId, branch.id) + .then(() => { + onDeleted?.() + onClose() + }) + .catch((err: Error) => { + toast({ title: 'Failed to delete branch', description: err.message || 'Unknown error', variant: 'fail' }) + }) + .finally(() => setIsPending(false)) + } + + return ( +
+
+
+

Delete Branch

+

+ Are you sure you want to delete{' '} + {branch.name}? This + action cannot be undone. +

+
+ + +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/index.ts b/src/frontend/components/_features/[workspace]/branches/index.ts new file mode 100644 index 000000000..6ab4e597f --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/index.ts @@ -0,0 +1,5 @@ +export { BranchStatusBar } from './branch-status-bar' +export { BranchSwitcherModal } from './branch-switcher-modal' +export { CreateBranchModal } from './create-branch-modal' +export { DeleteBranchModal } from './delete-branch-modal' +export { UnsavedChangesWarningModal } from './unsaved-changes-warning-modal' diff --git a/src/frontend/components/_features/[workspace]/branches/unsaved-changes-warning-modal.tsx b/src/frontend/components/_features/[workspace]/branches/unsaved-changes-warning-modal.tsx new file mode 100644 index 000000000..99b0a87c3 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/unsaved-changes-warning-modal.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react' + +type UnsavedChangesWarningModalProps = { + isOpen: boolean + targetBranchName: string + onDiscard: () => void + onCancel: () => void +} + +export function UnsavedChangesWarningModal({ + isOpen, + targetBranchName, + onDiscard, + onCancel, +}: UnsavedChangesWarningModalProps) { + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onCancel]) + + if (!isOpen) return null + + return ( +
+
+
+

Unsaved Changes

+

+ You have uncommitted changes. Switching to {targetBranchName} will discard them. This action + cannot be undone. +

+
+ + +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx index d0fccff10..178a5fe46 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx @@ -8,9 +8,10 @@ type GraphicalEditorProps = ComponentPropsWithoutRef<'div'> & { name: string language: 'ld' | 'sfc' | 'fbd' path: string + readOnly?: boolean } -const GraphicalEditor = ({ language }: GraphicalEditorProps) => { +const GraphicalEditor = ({ language, readOnly }: GraphicalEditorProps) => { const editorComponents = { sfc: SfcEditor, fbd: FbdEditor, @@ -20,10 +21,11 @@ const GraphicalEditor = ({ language }: GraphicalEditorProps) => { const EditorComponent = editorComponents[language] return ( -
- {/*
*/} +
- {/*
*/} + {readOnly && ( +
+ )}
) } diff --git a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx new file mode 100644 index 000000000..4ac69f350 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx @@ -0,0 +1,700 @@ +import Editor from '@monaco-editor/react' +import { File, Folder, FolderOpen, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { PendingChange } from '../../../../../middleware/shared/ports/version-control-port' +import { useProject, useVersionControl } from '../../../../../middleware/shared/providers' +import { useOpenPLCStore } from '../../../../store' +import type { TabsProps } from '../../../../store/slices/tabs' +import { CreateEditorObjectFromTab } from '../../../../store/slices/tabs/utils' +import { serializePouToText } from '../../../../utils/PLC/pou-text-serializer' +import { sanitizePou } from '../../../../utils/save-project' +import { cn } from '../../../../utils/cn' +import { toast } from '../../../../utils/toast' +import { DiscardConfirmationModal } from './modals/discard-confirmation-modal' + +type ChangesSectionProps = { + projectId: string +} + +const STATUS_LABEL: Record = { + modified: 'M', + added: 'A', + deleted: 'D', +} + +const STATUS_COLOR: Record = { + modified: 'text-yellow-500 dark:text-yellow-400', + added: 'text-green-500 dark:text-green-400', + deleted: 'text-red-500 dark:text-red-400', +} + +const STATUS_TOOLTIP: Record = { + modified: 'Modified -- File has been changed since last commit', + added: 'Added -- New file not in previous commit', + deleted: 'Deleted -- File has been removed', +} + +// --------------------------------------------------------------------------- +// File content resolution & preview +// --------------------------------------------------------------------------- + +function getLanguageFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() + switch (ext) { + case 'json': + return 'json' + case 'st': + case 'il': + case 'ld': + case 'fbd': + return 'st' + case 'py': + return 'python' + case 'cpp': + return 'cpp' + default: + return 'plaintext' + } +} + +function FilePreviewModal({ filePath, content, onClose }: { filePath: string; content: string; onClose: () => void }) { + const isDark = document.documentElement.classList.contains('dark') + + return ( +
+
e.stopPropagation()} + > +
+ {filePath} + +
+
+ +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Tree types & helpers +// --------------------------------------------------------------------------- + +type ChangedFile = { path: string; status: string } + +type FileTreeNode = { + name: string + path: string + type: 'file' | 'folder' + status?: string + children?: FileTreeNode[] +} + +function buildChangesTree(files: ChangedFile[]): FileTreeNode[] { + const root: FileTreeNode[] = [] + + for (const file of files) { + const parts = file.path.split('/').filter(Boolean) + if (parts.length === 0) continue + let current = root + + for (let i = 0; i < parts.length; i++) { + const name = parts[i] + const isFile = i === parts.length - 1 + + let existing = current.find((n) => n.name === name) + if (!existing) { + existing = { + name, + path: parts.slice(0, i + 1).join('/'), + type: isFile ? 'file' : 'folder', + status: isFile ? file.status : undefined, + children: isFile ? undefined : [], + } + current.push(existing) + } + if (!isFile) { + if (!existing.children) { + existing.children = [] + existing.type = 'folder' + } + current = existing.children + } + } + } + + const sortNodes = (nodes: FileTreeNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'folder' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const node of nodes) { + if (node.children) sortNodes(node.children) + } + } + sortNodes(root) + return root +} + +function collectFilePaths(node: FileTreeNode): string[] { + if (node.type === 'file') return [node.path] + return node.children?.flatMap(collectFilePaths) ?? [] +} + +// --------------------------------------------------------------------------- +// Tree item component +// --------------------------------------------------------------------------- + +function ChangesTreeItem({ + node, + depth, + selectedFiles, + onToggleFile, + onToggleFolder, + expandedFolders, + onExpandToggle, + onFileClick, +}: { + node: FileTreeNode + depth: number + selectedFiles: Set + onToggleFile: (path: string) => void + onToggleFolder: (paths: string[], select: boolean) => void + expandedFolders: Set + onExpandToggle: (path: string) => void + onFileClick: (path: string) => void +}) { + if (node.type === 'folder') { + const isExpanded = expandedFolders.has(node.path) + const childPaths = collectFilePaths(node) + const allChildrenSelected = childPaths.length > 0 && childPaths.every((p) => selectedFiles.has(p)) + const someChildrenSelected = childPaths.some((p) => selectedFiles.has(p)) + + return ( +
+
+ { + if (el) el.indeterminate = someChildrenSelected && !allChildrenSelected + }} + onChange={() => onToggleFolder(childPaths, !allChildrenSelected)} + onClick={(e) => e.stopPropagation()} + className='h-3 w-3 shrink-0 cursor-pointer rounded border-neutral-300 accent-blue-500 dark:border-neutral-600' + /> + + {childPaths.length} +
+ {isExpanded && + node.children?.map((child) => ( + + ))} +
+ ) + } + + return ( +
+ onToggleFile(node.path)} + onClick={(e) => e.stopPropagation()} + className='h-3 w-3 shrink-0 cursor-pointer rounded border-neutral-300 accent-blue-500 dark:border-neutral-600' + /> + + + + {STATUS_LABEL[node.status ?? ''] ?? node.status} + +
+ ) +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function ChangesSection({ projectId }: ChangesSectionProps) { + const versionControl = useVersionControl() + const projectPort = useProject() + const { + versionControlActions, + sharedWorkspaceActions, + project, + deviceDefinitions, + tabsActions: { updateTabs }, + editorActions: { setEditor, addModel, getEditorFromEditors }, + } = useOpenPLCStore() + + const pous = project.data.pous + + const [files, setFiles] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [message, setMessage] = useState('') + const [showDiscardModal, setShowDiscardModal] = useState(false) + const [selectedFiles, setSelectedFiles] = useState>(new Set()) + const [errorMessage, setErrorMessage] = useState(null) + const [isCommitting, setIsCommitting] = useState(false) + const [isDiscarding, setIsDiscarding] = useState(false) + const [expandedFolders, setExpandedFolders] = useState>(new Set()) + const [previewFile, setPreviewFile] = useState<{ path: string; content: string } | null>(null) + + const tree = useMemo(() => buildChangesTree(files), [files]) + + // Auto-expand all folders + const folderKey = useMemo(() => { + const folders = new Set() + for (const file of files) { + const parts = file.path.split('/').filter(Boolean) + for (let i = 1; i < parts.length; i++) { + folders.add(parts.slice(0, i).join('/')) + } + } + return folders + }, [files]) + + const prevFolderKeyRef = useRef>(new Set()) + useEffect(() => { + if (folderKey.size > 0 && folderKey.size !== prevFolderKeyRef.current.size) { + setExpandedFolders(folderKey) + prevFolderKeyRef.current = folderKey + } + }, [folderKey]) + + // Fetch changes + const fetchChanges = useCallback(async () => { + if (!versionControl) return + setIsFetching(true) + try { + const data = await versionControl.getChanges(projectId) + setFiles(data.changes) + versionControlActions.setPendingChangesCount(data.changes.length) + } catch { + setFiles([]) + } finally { + setIsLoading(false) + setIsFetching(false) + } + }, [projectId, versionControl, versionControlActions]) + + useEffect(() => { + void fetchChanges() + }, [fetchChanges]) + + // Auto-select all files on initial load or when file list changes + const fileListKey = useMemo( + () => + files + .map((f) => f.path) + .sort() + .join('\n'), + [files], + ) + const prevFileListKey = useRef(fileListKey) + const isInitialLoad = useRef(true) + + useEffect(() => { + if (isInitialLoad.current || fileListKey !== prevFileListKey.current) { + setSelectedFiles(new Set(files.map((f) => f.path))) + prevFileListKey.current = fileListKey + isInitialLoad.current = false + } + }, [fileListKey, files]) + + const allSelected = files.length > 0 && selectedFiles.size === files.length + const someSelected = selectedFiles.size > 0 && selectedFiles.size < files.length + + const toggleFile = (path: string) => { + setSelectedFiles((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + const toggleFolderFiles = (paths: string[], select: boolean) => { + setSelectedFiles((prev) => { + const next = new Set(prev) + for (const p of paths) { + if (select) next.add(p) + else next.delete(p) + } + return next + }) + } + + const toggleAll = () => { + if (allSelected) setSelectedFiles(new Set()) + else setSelectedFiles(new Set(files.map((f) => f.path))) + } + + const toggleExpand = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + const handleFileClick = useCallback( + (filePath: string) => { + // POU files open in the editor + if (filePath.startsWith('pous/')) { + const filename = filePath.split('/').pop() ?? '' + const dotIndex = filename.lastIndexOf('.') + if (dotIndex === -1) return + const pouName = filename.substring(0, dotIndex) + + const pou = pous.find((p) => p.name === pouName) + if (!pou) return + + const tabToBeCreated = { + name: pou.name, + path: `/pous/${pou.pouType}s/${pou.name}`, + elementType: { type: pou.pouType, language: pou.body.language }, + } as TabsProps + + updateTabs(tabToBeCreated) + const editorObj = getEditorFromEditors(pouName) + if (!editorObj) { + const model = CreateEditorObjectFromTab(tabToBeCreated) + addModel(model) + setEditor(model) + return + } + addModel(editorObj) + setEditor(editorObj) + return + } + + // Non-POU files: resolve content from store and show in preview modal + try { + let content: string | null = null + + if (filePath === 'project.json') { + content = JSON.stringify( + { name: project.meta.name, type: project.meta.type, path: project.meta.path }, + null, + 2, + ) + } else if (filePath === 'devices/configuration.json') { + content = JSON.stringify(deviceDefinitions.configuration, null, 2) + } else if (filePath === 'devices/pin-mapping.json') { + content = JSON.stringify(deviceDefinitions.pinMapping, null, 2) + } else if (filePath.startsWith('devices/remote/')) { + const name = filePath.split('/').pop()?.replace('.json', '') + const rd = project.data.remoteDevices?.find((d) => d.name === name) + if (rd) content = JSON.stringify(rd, null, 2) + } else if (filePath.startsWith('devices/servers/')) { + const name = filePath.split('/').pop()?.replace('.json', '') + const srv = project.data.servers?.find((s) => s.name === name) + if (srv) content = JSON.stringify(srv, null, 2) + } + + // Try POU serialization as fallback (in case the path format differs) + if (content === null) { + const filename = filePath.split('/').pop() ?? '' + const dotIndex = filename.lastIndexOf('.') + if (dotIndex > 0) { + const pouName = filename.substring(0, dotIndex) + const pou = pous.find((p) => p.name === pouName) + if (pou) { + const sanitized = sanitizePou(pou, undefined) + content = serializePouToText(sanitized) + } + } + } + + if (content !== null) { + setPreviewFile({ path: filePath, content }) + } + } catch { + // Serialization failed — ignore + } + }, + [pous, project, deviceDefinitions, updateTabs, getEditorFromEditors, addModel, setEditor], + ) + + const hasChanges = files.length > 0 + const canCommit = message.trim().length > 0 && selectedFiles.size > 0 + + const handleCommit = async () => { + if (!canCommit || !versionControl) return + + setIsCommitting(true) + setErrorMessage(null) + + try { + // Re-fetch to avoid stale state + const freshData = await versionControl.getChanges(projectId) + const freshFiles = freshData.changes + if (freshFiles.length === 0) { + setIsCommitting(false) + return + } + + const validPaths = [...selectedFiles].filter((p) => freshFiles.some((f) => f.path === p)) + if (validPaths.length === 0) { + setIsCommitting(false) + return + } + + await versionControl.createCommit( + projectId, + message.trim(), + validPaths.length === freshFiles.length ? undefined : validPaths, + ) + + setMessage('') + setSelectedFiles(new Set()) + await fetchChanges() + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to create commit' + setErrorMessage(msg) + } finally { + setIsCommitting(false) + } + } + + const handleDiscard = async () => { + if (!versionControl) return + + setIsDiscarding(true) + setErrorMessage(null) + + try { + const selectedPaths = [...selectedFiles] + await versionControl.discardChanges(projectId, selectedPaths.length === files.length ? undefined : selectedPaths) + setShowDiscardModal(false) + + // Reload project data in-place (no hard page reload) + try { + const result = await projectPort.openProjectByPath(projectId) + if (result.success && result.data) { + sharedWorkspaceActions.handleOpenProjectResponse(result.data) + } + } catch { + toast({ title: 'Failed to reload project after discard', variant: 'fail' }) + } + await fetchChanges() + } catch (error) { + setShowDiscardModal(false) + const msg = error instanceof Error ? error.message : 'Failed to discard changes' + setErrorMessage(msg) + setIsDiscarding(false) + } + } + + if (isLoading) { + return ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) + } + + return ( +
+ {/* Subheader */} +
+
+ {hasChanges && ( + { + if (el) el.indeterminate = someSelected + }} + onChange={toggleAll} + title={allSelected ? 'Deselect all' : 'Select all'} + className='h-3 w-3 cursor-pointer rounded border-neutral-300 accent-blue-500 dark:border-neutral-600' + /> + )} + + {!hasChanges + ? 'No changes' + : someSelected + ? `${selectedFiles.size} of ${files.length} selected` + : `${files.length} file${files.length > 1 ? 's' : ''} changed`} + +
+ +
+ + {/* File tree */} +
+ {!hasChanges ? ( +

No changes to commit

+ ) : ( +
+ {tree.map((node) => ( + + ))} +
+ )} +
+ + {/* Error */} + {errorMessage && ( +
+
+

{errorMessage}

+ +
+
+ )} + + {/* Commit form */} +
+
+