From 1b9f1668f15d8df825027d50e343bf5c6ea512da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 17:59:16 -0300 Subject: [PATCH 1/4] fix(datatypes): wire undo/redo snapshots and dirty tracking for data type edits Datatype editors called the POU-keyed snapshot capture with a data type name, so every capture and undo/redo silently no-oped. Snapshot capture and snapshotActions.undo/redo now branch on data type names, restoring via the previously unwired projectActions.applyDatatypeSnapshot. Content edits (enum values, struct fields, array dimensions/base type/initial value) now mark the file and workspace unsaved via handleFileAndWorkspaceSavedState, matching the variables editor. The datatype header rename now goes through datatypeActions.rename, which validates the new name and rekeys the editor/tab/file entries instead of leaving the file slice orphaned. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../_features/[workspace]/data-type/index.tsx | 20 ++-- .../_molecules/data-types/array/index.tsx | 13 +- .../data-types/array/table/index.tsx | 2 + .../data-types/enumerated/index.tsx | 3 + .../data-types/enumerated/table/index.tsx | 2 + .../_molecules/data-types/structure/index.tsx | 3 + .../data-types/structure/table/index.tsx | 2 + src/frontend/hooks/use-pou-snapshot.ts | 9 +- .../store/__tests__/shared-slice.test.ts | 91 ++++++++++++++ src/frontend/store/slices/shared/slice.ts | 112 +++++++++++------- src/frontend/store/slices/shared/types.ts | 3 + 11 files changed, 199 insertions(+), 61 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index 42b1046a2..a177ff0e6 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -7,6 +7,7 @@ import { InputWithRef } from '../../../_atoms/input' import { ArrayDataType } from '../../../_molecules/data-types/array' import { EnumeratorDataType } from '../../../_molecules/data-types/enumerated' import { StructureDataType } from '../../../_molecules/data-types/structure' +import { toast } from '../../[app]/toast/use-toast' type DatatypeEditorProps = ComponentPropsWithoutRef<'div'> & { dataTypeName: string @@ -17,9 +18,7 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { project: { data: { dataTypes }, }, - tabsActions: { updateTabName }, - editorActions: { updateEditorModel }, - projectActions: { updateDatatype }, + datatypeActions: { rename }, searchQuery, } = useOpenPLCStore() const [editorContent, setEditorContent] = useState() @@ -52,14 +51,13 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { const handleBlur = (e: React.FocusEvent) => { const { value } = e.target if (dataTypeName !== value) { - // `updateDatatype` is a full replace. Spread the current - // entry so the rename only changes `name` — without this the - // entry would lose every other field (variable / values / - // dimensions / baseType / initialValue). - if (!editorContent) return - updateDatatype(dataTypeName, { ...editorContent, name: value }) - updateEditorModel(dataTypeName, value) - updateTabName(dataTypeName, value) + // `datatypeActions.rename` validates the new name and rekeys the + // editor model, tab, and file entry, then flags the file dirty. + const result = rename(dataTypeName, value) + if (!result.ok) { + setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent)) + toast({ title: 'Rename failed', description: result.message, variant: 'fail' }) + } setIsEditing(false) } } diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index a8ecec386..a09052535 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -30,6 +30,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { data: { dataTypes }, }, libraries: sliceLibraries, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -92,17 +93,21 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const handleInitialValueChange = (e: ChangeEvent) => { setInitialValueData(e.target.value) + captureAndPush(editor.meta.name) const updatedData = { ...data } updatedData.initialValue = e.target.value updateDatatype(data.name, updatedData as PLCArrayDatatype) + handleFileAndWorkspaceSavedState(data.name) } const handleSelect = (definition: string, value: string) => { setBaseType(value) + captureAndPush(editor.meta.name) updateDatatype(data.name, { ...data, baseType: { value, definition }, } as PLCArrayDatatype) + handleFileAndWorkspaceSavedState(data.name) } // `updateDatatype` is a full replace — never pass a partial object, @@ -110,17 +115,15 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { // gets stripped and downstream selectors lose the entry. const writeDimensions = (newRows: PLCArrayDatatype['dimensions']) => { updateDatatype(data.name, { ...data, dimensions: newRows }) + handleFileAndWorkspaceSavedState(data.name) } const addNewRow = () => { + captureAndPush(editor.meta.name) + setTableData((prevRows) => { - const isFirst = prevRows.length === 0 const newRows = [...prevRows, { dimension: '' }] - if (isFirst) { - captureAndPush(editor.meta.name) - } - setArrayTable({ selectedRow: newRows.length - 1 }) writeDimensions(newRows) return newRows diff --git a/src/frontend/components/_molecules/data-types/array/table/index.tsx b/src/frontend/components/_molecules/data-types/array/table/index.tsx index ec1035076..8c4239190 100644 --- a/src/frontend/components/_molecules/data-types/array/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/table/index.tsx @@ -36,6 +36,7 @@ const DimensionsTable = ({ data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -48,6 +49,7 @@ const DimensionsTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'array') return updateDatatype(name, { ...current, dimensions: newDimensions }) + handleFileAndWorkspaceSavedState(name) } const columnHelper = createColumnHelper<{ dimension: string }>() diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index 93a794df5..44a7f5a92 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -19,6 +19,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { const { editor, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -46,6 +47,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { ...data, initialValue: value, }) + handleFileAndWorkspaceSavedState(data.name) } // `updateDatatype` is a full replace — spread `data` first so we @@ -53,6 +55,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { // downstream consumers. const writeValues = (newValues: PLCEnumeratedDatatype['values']) => { updateDatatype(data.name, { ...data, values: newValues }) + handleFileAndWorkspaceSavedState(data.name) } const addNewRow = () => { diff --git a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx index 4970dd488..0243d9f59 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx @@ -38,6 +38,7 @@ const EnumeratedTable = ({ data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -50,6 +51,7 @@ const EnumeratedTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'enumerated') return updateDatatype(name, { ...current, values: newValues }) + handleFileAndWorkspaceSavedState(name) } const columnHelper = createColumnHelper<{ description: string }>() diff --git a/src/frontend/components/_molecules/data-types/structure/index.tsx b/src/frontend/components/_molecules/data-types/structure/index.tsx index c5d934b20..fd20b81c0 100644 --- a/src/frontend/components/_molecules/data-types/structure/index.tsx +++ b/src/frontend/components/_molecules/data-types/structure/index.tsx @@ -21,6 +21,7 @@ const StructureDataType = () => { }, editorActions: { updateModelStructure }, projectActions: { updateDatatype, rearrangeStructureVariables }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -65,6 +66,7 @@ const StructureDataType = () => { const current = dataTypes.find((dt) => dt.name === editor.meta.name) if (!current || current.derivation !== 'structure') return updateDatatype(editor.meta.name, { ...current, variable: newVariables }) + handleFileAndWorkspaceSavedState(editor.meta.name) } const handleCreateStructureVariable = () => { @@ -178,6 +180,7 @@ const StructureDataType = () => { rowId: row ?? parseInt(editorStructure.selectedRow), newIndex: (row ?? parseInt(editorStructure.selectedRow)) + index, }) + handleFileAndWorkspaceSavedState(editor.meta.name) updateModelStructure({ selectedRow: parseInt(editorStructure.selectedRow) + index, }) diff --git a/src/frontend/components/_molecules/data-types/structure/table/index.tsx b/src/frontend/components/_molecules/data-types/structure/table/index.tsx index 2838a460d..390d8e365 100644 --- a/src/frontend/components/_molecules/data-types/structure/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/structure/table/index.tsx @@ -51,6 +51,7 @@ const StructureTable = ({ tableData, selectedRow, handleRowClick }: PLCStructure data: { dataTypes }, }, projectActions: { updateDatatype }, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -85,6 +86,7 @@ const StructureTable = ({ tableData, selectedRow, handleRowClick }: PLCStructure return variable }), }) + handleFileAndWorkspaceSavedState(editor.meta.name) return { ok: true, message: 'Data updated successfully.' } } catch (error) { console.error('Failed to update data:', error) diff --git a/src/frontend/hooks/use-pou-snapshot.ts b/src/frontend/hooks/use-pou-snapshot.ts index e7f715e90..aeaa86cb5 100644 --- a/src/frontend/hooks/use-pou-snapshot.ts +++ b/src/frontend/hooks/use-pou-snapshot.ts @@ -7,6 +7,8 @@ import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback' * Convenience hook wrapping snapshotActions.pushToHistory(). * Captures the current POU state (variables, body, globalVariables, and * graphical flow state for LD/FBD) and pushes it to the undo history. + * Names that resolve to a data type instead of a POU capture the data + * type entry (`dataTypes`) so datatype editors share the same history. * * State is read via getState() at capture time (not subscribed): the hook * never re-renders its consumers and `captureAndPush` keeps a stable identity. @@ -29,7 +31,12 @@ export function usePouSnapshot() { if (flushFlowWriteBacks(useOpenPLCStore.getState, pouName).length > 0) return const { project, ladderFlows, fbdFlows } = useOpenPLCStore.getState() const pou = project.data.pous.find((p) => p.name === pouName) - if (!pou) return + if (!pou) { + const dataType = project.data.dataTypes.find((d) => d.name === pouName) + if (!dataType) return + pushToHistory(pouName, { variables: [], body: null, dataTypes: [dataType] }) + return + } pushToHistory(pouName, { variables: pou.interface?.variables ?? [], diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 87c7f22c0..4475d53a6 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1463,6 +1463,97 @@ describe('createSharedSlice', () => { expect(store.getState().project.data.pous.find((p) => p.name === 'Main')!.body.value).toBe('v2') }) }) + + // ----------------------------------------------------------------------- + // undo / redo for data types + // ----------------------------------------------------------------------- + describe('undo/redo for data types', () => { + const edited = { + name: 'Colors', + derivation: 'enumerated' as const, + values: [{ description: 'RED' }], + initialValue: '', + } + + beforeEach(() => { + store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) + }) + + it('undo restores the snapshot data type and moves the current entry to future', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + + expect(store.getState().snapshotActions.undo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(initial) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(0) + expect(history.future).toHaveLength(1) + expect(history.future[0].dataTypes).toEqual([edited]) + }) + + it('redo reapplies the undone data type edit', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().snapshotActions.redo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(1) + expect(history.future).toHaveLength(0) + expect(history.past[0].dataTypes).toEqual([initial]) + }) + + it('undo marks the data type file saved when history returns to the saved depth', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.markSaved('Colors') + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().fileActions.updateFile({ name: 'Colors', saved: false }) + + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(true) + }) + + it('undo leaves the data type untouched when the snapshot has no dataTypes entry', () => { + store.getState().projectActions.updateDatatype('Colors', edited) + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null }) + + expect(store.getState().snapshotActions.undo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(0) + expect(history.future).toHaveLength(1) + expect(history.future[0].dataTypes).toEqual([edited]) + }) + + it('redo leaves the data type untouched when the future snapshot has no dataTypes entry', () => { + store.getState().projectActions.updateDatatype('Colors', edited) + store.setState({ + undoRedo: { + Colors: { + past: [], + future: [{ variables: [], body: null }], + savedAtDepth: null, + }, + }, + }) + + expect(store.getState().snapshotActions.redo('Colors')).toBe(true) + + expect(store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')).toEqual(edited) + const history = store.getState().undoRedo['Colors'] + expect(history.past).toHaveLength(1) + expect(history.past[0].dataTypes).toEqual([edited]) + }) + }) }) // ========================================================================= diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index cc2b78116..00b78eb43 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1002,17 +1002,24 @@ const createSharedSlice: StateCreator = (s const snapshot = history.past[history.past.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return true + const dataType = pou ? undefined : state.project.data.dataTypes.find((d) => d.name === pouName) // Save current state to future. Plain references — the store is // immer-managed (frozen, copy-on-write), so later edits can never // reach a captured snapshot. - const currentSnapshot: PouHistorySnapshot = { - variables: pou.interface?.variables ?? [], - body: pou.body.value, - ladderFlow: state.ladderFlows.find((f) => f.name === pouName), - fbdFlow: state.fbdFlows.find((f) => f.name === pouName), - globalVariables: state.project.data.configurations.resource.globalVariables, + let currentSnapshot: PouHistorySnapshot + if (pou) { + currentSnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, + } + } else if (dataType) { + currentSnapshot = { variables: [], body: null, dataTypes: [dataType] } + } else { + return true } setState( @@ -1025,22 +1032,27 @@ const createSharedSlice: StateCreator = (s }), ) - state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { - language: pou.body.language, - value: snapshot.body, - }) - if (snapshot.globalVariables) { - state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) - } - // Restore graphical flow state (nodes, edges, positions) - if (snapshot.ladderFlow) { - state.ladderFlowActions.applyLadderFlowSnapshot({ - editorName: pouName, - snapshot: snapshot.ladderFlow as LadderFlowType, + if (pou) { + state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { + language: pou.body.language, + value: snapshot.body, }) - } - if (snapshot.fbdFlow) { - state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + if (snapshot.globalVariables) { + state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) + } + // Restore graphical flow state (nodes, edges, positions) + if (snapshot.ladderFlow) { + state.ladderFlowActions.applyLadderFlowSnapshot({ + editorName: pouName, + snapshot: snapshot.ladderFlow as LadderFlowType, + }) + } + if (snapshot.fbdFlow) { + state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + } + } else { + const restoredDataType = snapshot.dataTypes?.[0] + if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) } // Check if we've returned to the saved state @@ -1060,15 +1072,22 @@ const createSharedSlice: StateCreator = (s const snapshot = history.future[history.future.length - 1] const pou = state.project.data.pous.find((p) => p.name === pouName) - if (!pou) return true + const dataType = pou ? undefined : state.project.data.dataTypes.find((d) => d.name === pouName) // Save current state to past. Plain references — see undo. - const currentSnapshot: PouHistorySnapshot = { - variables: pou.interface?.variables ?? [], - body: pou.body.value, - ladderFlow: state.ladderFlows.find((f) => f.name === pouName), - fbdFlow: state.fbdFlows.find((f) => f.name === pouName), - globalVariables: state.project.data.configurations.resource.globalVariables, + let currentSnapshot: PouHistorySnapshot + if (pou) { + currentSnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, + } + } else if (dataType) { + currentSnapshot = { variables: [], body: null, dataTypes: [dataType] } + } else { + return true } setState( @@ -1081,22 +1100,27 @@ const createSharedSlice: StateCreator = (s }), ) - state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { - language: pou.body.language, - value: snapshot.body, - }) - if (snapshot.globalVariables) { - state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) - } - // Restore graphical flow state (nodes, edges, positions) - if (snapshot.ladderFlow) { - state.ladderFlowActions.applyLadderFlowSnapshot({ - editorName: pouName, - snapshot: snapshot.ladderFlow as LadderFlowType, + if (pou) { + state.projectActions.applyPouSnapshot(pouName, snapshot.variables, { + language: pou.body.language, + value: snapshot.body, }) - } - if (snapshot.fbdFlow) { - state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + if (snapshot.globalVariables) { + state.projectActions.setGlobalVariables({ variables: snapshot.globalVariables }) + } + // Restore graphical flow state (nodes, edges, positions) + if (snapshot.ladderFlow) { + state.ladderFlowActions.applyLadderFlowSnapshot({ + editorName: pouName, + snapshot: snapshot.ladderFlow as LadderFlowType, + }) + } + if (snapshot.fbdFlow) { + state.fbdFlowActions.applyFBDFlowSnapshot({ editorName: pouName, snapshot: snapshot.fbdFlow as FBDFlowType }) + } + } else { + const restoredDataType = snapshot.dataTypes?.[0] + if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) } // Check if we've returned to the saved state diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 5025c4129..64ac74eed 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -1,6 +1,7 @@ import type { DeviceConfiguration, DevicePin, + PLCDataType, PLCProjectData, PLCVariable, ProjectMeta, @@ -62,6 +63,8 @@ export type PouHistorySnapshot = { globalVariables?: PLCVariable[] ladderFlow?: unknown fbdFlow?: unknown + /** Set when the history key is a data type instead of a POU. */ + dataTypes?: PLCDataType[] } export type PouHistory = { From c2b14ab23d7218daa1bb348d9ac211cbc9892fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 21:51:59 -0300 Subject: [PATCH 2/4] fix(datatypes): flag undo/redo divergence dirty and batch initial-value history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undo/redo now marks the file and workspace unsaved whenever history does not land on the saved depth — previously an undo away from the saved state kept the file flagged saved while the store diverged from disk, so the next save-all silently skipped the revert. The array initial-value input re-syncs from the store on external changes (undo/redo) instead of keeping its mount-time value, and captures one history entry per typing burst (rearmed on blur or external change) rather than one per keystroke. Own writes are tracked in a ref because the data prop lags one render behind the store. The enumerated initial value gets the same store re-sync. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../_molecules/data-types/array/index.tsx | 30 +++++++++++++++---- .../data-types/enumerated/index.tsx | 3 +- .../store/__tests__/shared-slice.test.ts | 27 +++++++++++++++++ src/frontend/store/slices/shared/slice.ts | 6 ++++ 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index a09052535..fb70e63da 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, ComponentPropsWithoutRef, useEffect, useState } from 'react' +import { ChangeEvent, ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react' import { baseTypeEnum } from '../../../../../middleware/shared/ports/plc-schemas' import type { PLCDataType } from '../../../../../middleware/shared/ports/types' @@ -73,7 +73,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const ROWS_NOT_SELECTED = -1 const [arrayTable, setArrayTable] = useState<{ selectedRow: number }>({ selectedRow: ROWS_NOT_SELECTED }) - const [initialValueData, setInitialValueData] = useState('') + const [initialValueData, setInitialValueData] = useState(data.initialValue || '') const [baseType, setBaseType] = useState(data.baseType.value) const [tableData, setTableData] = useState([]) @@ -82,10 +82,21 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { setTableData(data.dimensions) }, [data.dimensions]) + // One history entry per typing burst: armed on the first keystroke, + // rearmed on blur or when the store value changes under us (undo/redo). + const initialValueCaptured = useRef(false) + // `data` lags one render behind the store (the parent copies it via effect), + // so compare against our own last write to spot genuinely external changes. + const lastWrittenInitialValue = useRef(data.initialValue || '') + useEffect(() => { - setInitialValueData(data.initialValue || '') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const storeValue = data.initialValue || '' + if (storeValue !== lastWrittenInitialValue.current) { + setInitialValueData(storeValue) + lastWrittenInitialValue.current = storeValue + initialValueCaptured.current = false + } + }, [data.initialValue]) useEffect(() => { setBaseType(data.baseType.value) @@ -93,7 +104,11 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const handleInitialValueChange = (e: ChangeEvent) => { setInitialValueData(e.target.value) - captureAndPush(editor.meta.name) + lastWrittenInitialValue.current = e.target.value + if (!initialValueCaptured.current) { + captureAndPush(editor.meta.name) + initialValueCaptured.current = true + } const updatedData = { ...data } updatedData.initialValue = e.target.value updateDatatype(data.name, updatedData as PLCArrayDatatype) @@ -214,6 +229,9 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { { + initialValueCaptured.current = false + }} value={initialValueData} className='flex h-7 w-full max-w-44 items-center justify-between gap-2 rounded-lg border border-neutral-400 bg-white px-3 py-2 font-caption text-xs font-normal text-neutral-950 focus-within:border-brand focus:border-brand focus:outline-none dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100' /> diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index 44a7f5a92..aec948fd9 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -33,8 +33,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { useEffect(() => { setInitialValueData(data.initialValue || '') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [data.initialValue]) useEffect(() => { setTableData(data.values) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 4475d53a6..f6c9536c4 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1521,6 +1521,33 @@ describe('createSharedSlice', () => { expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(true) }) + it('undo marks the data type file unsaved when history diverges from the saved depth', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [edited] }) + store.getState().snapshotActions.markSaved('Colors') + store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) + store.getState().workspaceActions.setEditingState('saved') + + store.getState().snapshotActions.undo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('redo marks the data type file unsaved when history diverges from the saved depth', () => { + const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().snapshotActions.undo('Colors') + store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) + store.getState().workspaceActions.setEditingState('saved') + + store.getState().snapshotActions.redo('Colors') + + expect(store.getState().fileActions.getSavedState({ name: 'Colors' })).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + it('undo leaves the data type untouched when the snapshot has no dataTypes entry', () => { store.getState().projectActions.updateDatatype('Colors', edited) store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null }) diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 00b78eb43..ee0d6aa4b 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1059,6 +1059,9 @@ const createSharedSlice: StateCreator = (s const afterUndo = getState().undoRedo[pouName] if (afterUndo?.savedAtDepth !== null && afterUndo?.savedAtDepth === afterUndo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) + } else { + // Diverged from the on-disk state — flag it or the next save-all skips the revert. + getState().sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) } return true }, @@ -1127,6 +1130,9 @@ const createSharedSlice: StateCreator = (s const afterRedo = getState().undoRedo[pouName] if (afterRedo?.savedAtDepth !== null && afterRedo?.savedAtDepth === afterRedo?.past.length) { getState().fileActions.updateFile({ name: pouName, saved: true }) + } else { + // Diverged from the on-disk state — flag it or the next save-all skips the revert. + getState().sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) } return true }, From d42ba16a9cb4914696f3b63ab651c2ee9c86a862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Wed, 5 Aug 2026 22:48:45 -0300 Subject: [PATCH 3/4] test(datatypes): drop non-null assertions from datatype history tests Replace find(...)! lookups with a throwing helper per the coding guideline banning non-null assertions. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../store/__tests__/shared-slice.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index f6c9536c4..5d43bd02b 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1479,8 +1479,14 @@ describe('createSharedSlice', () => { store.getState().datatypeActions.create({ name: 'Colors', derivation: 'enumerated' }) }) + const getColorsDataType = () => { + const dataType = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors') + if (!dataType) throw new Error('Colors data type missing') + return dataType + } + it('undo restores the snapshot data type and moves the current entry to future', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().projectActions.updateDatatype('Colors', edited) @@ -1494,7 +1500,7 @@ describe('createSharedSlice', () => { }) it('redo reapplies the undone data type edit', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().projectActions.updateDatatype('Colors', edited) store.getState().snapshotActions.undo('Colors') @@ -1509,7 +1515,7 @@ describe('createSharedSlice', () => { }) it('undo marks the data type file saved when history returns to the saved depth', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().snapshotActions.markSaved('Colors') store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) @@ -1522,7 +1528,7 @@ describe('createSharedSlice', () => { }) it('undo marks the data type file unsaved when history diverges from the saved depth', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [edited] }) store.getState().snapshotActions.markSaved('Colors') @@ -1536,7 +1542,7 @@ describe('createSharedSlice', () => { }) it('redo marks the data type file unsaved when history diverges from the saved depth', () => { - const initial = store.getState().project.data.dataTypes.find((d) => d.name === 'Colors')! + const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().snapshotActions.undo('Colors') store.getState().fileActions.updateFile({ name: 'Colors', saved: true }) From 8d070f5d9c55b66e0ffe269854d09caf97c959cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Thu, 6 Aug 2026 10:47:24 -0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(datatypes):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20capture=20test,=20name-source=20unification,=20rena?= =?UTF-8?q?me=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups from PR #649/#989 (Gustavo): - Add capture-side tests for usePouSnapshot: the datatype fallback (the core of the original bug) was only covered on the restore side, so deleting it left the suite green. - Unify capture and dirty-marking on editor.meta.name in the datatype molecules: data.name lags one render behind the store, so right after a rename the dirty call hit the orphaned old file key and silently marked nothing. - Rekey the undoRedo bucket in renameElement (new snapshotActions.renameHistory): history was orphaned under the old name after rename, turning undo into a silent no-op. Restored datatype snapshots pin name to the current history key so pre-rename snapshots can't desync tabs/files/editors. - Document the single-element dataTypes shape and the null body of datatype snapshots in PouHistorySnapshot. DOPE-534 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014LCcPrjCcCF29NQbG8xbwA --- .../_molecules/data-types/array/index.tsx | 6 +- .../data-types/array/table/index.tsx | 2 +- .../data-types/enumerated/index.tsx | 4 +- .../data-types/enumerated/table/index.tsx | 2 +- .../hooks/__tests__/use-pou-snapshot.test.ts | 66 +++++++++++++++++++ .../store/__tests__/shared-slice.test.ts | 34 ++++++++++ src/frontend/store/slices/shared/slice.ts | 27 +++++++- src/frontend/store/slices/shared/types.ts | 5 +- 8 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 src/frontend/hooks/__tests__/use-pou-snapshot.test.ts diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index fb70e63da..0b236d98e 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -112,7 +112,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const updatedData = { ...data } updatedData.initialValue = e.target.value updateDatatype(data.name, updatedData as PLCArrayDatatype) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const handleSelect = (definition: string, value: string) => { @@ -122,7 +122,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { ...data, baseType: { value, definition }, } as PLCArrayDatatype) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — never pass a partial object, @@ -130,7 +130,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { // gets stripped and downstream selectors lose the entry. const writeDimensions = (newRows: PLCArrayDatatype['dimensions']) => { updateDatatype(data.name, { ...data, dimensions: newRows }) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const addNewRow = () => { diff --git a/src/frontend/components/_molecules/data-types/array/table/index.tsx b/src/frontend/components/_molecules/data-types/array/table/index.tsx index 8c4239190..8b84e1413 100644 --- a/src/frontend/components/_molecules/data-types/array/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/table/index.tsx @@ -49,7 +49,7 @@ const DimensionsTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'array') return updateDatatype(name, { ...current, dimensions: newDimensions }) - handleFileAndWorkspaceSavedState(name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const columnHelper = createColumnHelper<{ dimension: string }>() diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index aec948fd9..6c57a6f06 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -46,7 +46,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { ...data, initialValue: value, }) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — spread `data` first so we @@ -54,7 +54,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { // downstream consumers. const writeValues = (newValues: PLCEnumeratedDatatype['values']) => { updateDatatype(data.name, { ...data, values: newValues }) - handleFileAndWorkspaceSavedState(data.name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const addNewRow = () => { diff --git a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx index 0243d9f59..d6a3b4441 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/table/index.tsx @@ -51,7 +51,7 @@ const EnumeratedTable = ({ const current = dataTypes.find((dt) => dt.name === name) if (!current || current.derivation !== 'enumerated') return updateDatatype(name, { ...current, values: newValues }) - handleFileAndWorkspaceSavedState(name) + handleFileAndWorkspaceSavedState(editor.meta.name) } const columnHelper = createColumnHelper<{ description: string }>() diff --git a/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts b/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts new file mode 100644 index 000000000..95c80713a --- /dev/null +++ b/src/frontend/hooks/__tests__/use-pou-snapshot.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react' + +import { useOpenPLCStore } from '../../store' +import { usePouSnapshot } from '../use-pou-snapshot' + +describe('usePouSnapshot', () => { + describe('captureAndPush', () => { + it('captures a data type snapshot keyed by the data type name', () => { + const created = useOpenPLCStore.getState().datatypeActions.create({ + name: 'CaptureColors', + derivation: 'enumerated', + }) + expect(created.ok).toBe(true) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureColors') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureColors'] + expect(bucket.past).toHaveLength(1) + expect(bucket.past[0].variables).toEqual([]) + expect(bucket.past[0].body).toBeNull() + expect(bucket.past[0].dataTypes).toEqual([ + expect.objectContaining({ name: 'CaptureColors', derivation: 'enumerated' }), + ]) + }) + + it('captures the current data type state, not the creation-time state', () => { + useOpenPLCStore.getState().datatypeActions.create({ name: 'CaptureDims', derivation: 'array' }) + const current = useOpenPLCStore.getState().project.data.dataTypes.find((d) => d.name === 'CaptureDims') + if (!current || current.derivation !== 'array') throw new Error('CaptureDims array data type missing') + useOpenPLCStore.getState().projectActions.updateDatatype('CaptureDims', { + ...current, + dimensions: [{ dimension: '0..7' }], + }) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureDims') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureDims'] + expect(bucket.past[0].dataTypes).toEqual([ + expect.objectContaining({ name: 'CaptureDims', dimensions: [{ dimension: '0..7' }] }), + ]) + }) + + it('captures a POU snapshot for POU names', () => { + useOpenPLCStore.getState().pouActions.create({ type: 'program', name: 'CaptureMain', language: 'st' }) + + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureMain') + + const bucket = useOpenPLCStore.getState().undoRedo['CaptureMain'] + expect(bucket.past).toHaveLength(1) + expect(bucket.past[0].dataTypes).toBeUndefined() + const pou = useOpenPLCStore.getState().project.data.pous.find((p) => p.name === 'CaptureMain') + if (!pou) throw new Error('CaptureMain POU missing') + expect(bucket.past[0].body).toBe(pou.body.value) + }) + + it('is a no-op for names matching neither a POU nor a data type', () => { + const { result } = renderHook(() => usePouSnapshot()) + result.current.captureAndPush('CaptureGhost') + + expect(useOpenPLCStore.getState().undoRedo['CaptureGhost']).toBeUndefined() + }) + }) +}) diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 5d43bd02b..b66c5ae9c 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1079,6 +1079,23 @@ describe('createSharedSlice', () => { }) }) + // ----------------------------------------------------------------------- + // renameHistory + // ----------------------------------------------------------------------- + describe('renameHistory', () => { + it('moves the undo/redo bucket to the new key', () => { + store.getState().snapshotActions.pushToHistory('Old', snapshot1) + store.getState().snapshotActions.renameHistory('Old', 'New') + expect(store.getState().undoRedo['Old']).toBeUndefined() + expect(store.getState().undoRedo['New'].past).toEqual([snapshot1]) + }) + + it('does nothing when the old key has no history', () => { + store.getState().snapshotActions.renameHistory('Missing', 'New') + expect(store.getState().undoRedo['New']).toBeUndefined() + }) + }) + // ----------------------------------------------------------------------- // undo // ----------------------------------------------------------------------- @@ -1567,6 +1584,23 @@ describe('createSharedSlice', () => { expect(history.future[0].dataTypes).toEqual([edited]) }) + it('rename keeps the history and undo restores content under the new name', () => { + const initial = getColorsDataType() + store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) + store.getState().projectActions.updateDatatype('Colors', edited) + + expect(store.getState().datatypeActions.rename('Colors', 'Palette').ok).toBe(true) + expect(store.getState().undoRedo['Colors']).toBeUndefined() + expect(store.getState().undoRedo['Palette'].past).toHaveLength(1) + + store.getState().snapshotActions.undo('Palette') + + // Content reverts, but the name stays pinned to the current key so + // tabs/files/editors (already rekeyed by the rename) don't desync. + const dataType = store.getState().project.data.dataTypes.find((d) => d.name === 'Palette') + expect(dataType).toEqual({ ...initial, name: 'Palette' }) + }) + it('redo leaves the data type untouched when the future snapshot has no dataTypes entry', () => { store.getState().projectActions.updateDatatype('Colors', edited) store.setState({ diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index ee0d6aa4b..85c357925 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -91,6 +91,10 @@ function renameElement( state.ladderFlowActions.renameLadderFlow(oldName, newName) state.fbdFlowActions.renameFBDFlow(oldName, newName) + // Follow the undo/redo stacks to the new key — otherwise the history is + // orphaned under the old name and undo becomes a silent no-op after rename. + state.snapshotActions.renameHistory(oldName, newName) + afterRename?.(oldName, newName) // A rename is an unsaved structural change — flag it dirty (the renamed file @@ -968,6 +972,17 @@ const createSharedSlice: StateCreator = (s ) }, + renameHistory: (oldName, newName) => { + setState( + produce((state: SharedRootState) => { + const history = state.undoRedo[oldName] + if (!history) return + delete state.undoRedo[oldName] + state.undoRedo[newName] = history + }), + ) + }, + markSaved: (pouName) => { setState( produce((state: SharedRootState) => { @@ -1052,7 +1067,11 @@ const createSharedSlice: StateCreator = (s } } else { const restoredDataType = snapshot.dataTypes?.[0] - if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) + // Pin the name to the current key: snapshots taken before a rename + // carry the old name, and restoring it would desync tabs/files/editors. + if (restoredDataType) { + state.projectActions.applyDatatypeSnapshot(pouName, { ...restoredDataType, name: pouName }) + } } // Check if we've returned to the saved state @@ -1123,7 +1142,11 @@ const createSharedSlice: StateCreator = (s } } else { const restoredDataType = snapshot.dataTypes?.[0] - if (restoredDataType) state.projectActions.applyDatatypeSnapshot(pouName, restoredDataType) + // Pin the name to the current key: snapshots taken before a rename + // carry the old name, and restoring it would desync tabs/files/editors. + if (restoredDataType) { + state.projectActions.applyDatatypeSnapshot(pouName, { ...restoredDataType, name: pouName }) + } } // Check if we've returned to the saved state diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 64ac74eed..7fccab121 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -59,11 +59,13 @@ export type SharedResponse = { export type PouHistorySnapshot = { variables: PLCVariable[] + /** POU body; `null` for data type snapshots (`dataTypes` carries the state instead). */ body: unknown globalVariables?: PLCVariable[] ladderFlow?: unknown fbdFlow?: unknown - /** Set when the history key is a data type instead of a POU. */ + /** Set when the history key is a data type instead of a POU. Always a + * single element today — the array shape mirrors `HistorySnapshot.dataTypes`. */ dataTypes?: PLCDataType[] } @@ -119,6 +121,7 @@ export type EtherCATDeviceActions = { export type SnapshotActions = { pushToHistory: (pouName: string, snapshot: PouHistorySnapshot) => void + renameHistory: (oldName: string, newName: string) => void markSaved: (pouName: string) => void markAllSaved: (except?: readonly string[]) => void /** @returns `false` when the POU's graphical body is stale, so history was left untouched. */