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..0b236d98e 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' @@ -30,6 +30,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { data: { dataTypes }, }, libraries: sliceLibraries, + sharedWorkspaceActions: { handleFileAndWorkspaceSavedState }, } = useOpenPLCStore() const { captureAndPush } = usePouSnapshot() @@ -72,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([]) @@ -81,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) @@ -92,17 +104,25 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { const handleInitialValueChange = (e: ChangeEvent) => { setInitialValueData(e.target.value) + 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) + handleFileAndWorkspaceSavedState(editor.meta.name) } const handleSelect = (definition: string, value: string) => { setBaseType(value) + captureAndPush(editor.meta.name) updateDatatype(data.name, { ...data, baseType: { value, definition }, } as PLCArrayDatatype) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — never pass a partial object, @@ -110,17 +130,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(editor.meta.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 @@ -211,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/array/table/index.tsx b/src/frontend/components/_molecules/data-types/array/table/index.tsx index ec1035076..8b84e1413 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(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 93a794df5..6c57a6f06 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() @@ -32,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) @@ -46,6 +46,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { ...data, initialValue: value, }) + handleFileAndWorkspaceSavedState(editor.meta.name) } // `updateDatatype` is a full replace — spread `data` first so we @@ -53,6 +54,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { // downstream consumers. const writeValues = (newValues: PLCEnumeratedDatatype['values']) => { updateDatatype(data.name, { ...data, values: newValues }) + 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 4970dd488..d6a3b4441 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(editor.meta.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/__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/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..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 // ----------------------------------------------------------------------- @@ -1463,6 +1480,147 @@ 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' }) + }) + + 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 = getColorsDataType() + 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 = getColorsDataType() + 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 = 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] }) + 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 marks the data type file unsaved when history diverges from the saved depth', () => { + 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') + 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 = getColorsDataType() + 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 }) + + 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('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({ + 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..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) => { @@ -1002,17 +1017,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,28 +1047,40 @@ 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] + // 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 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 }, @@ -1060,15 +1094,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,28 +1122,40 @@ 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] + // 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 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 }, diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index 5025c4129..7fccab121 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, @@ -58,10 +59,14 @@ 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. Always a + * single element today — the array shape mirrors `HistorySnapshot.dataTypes`. */ + dataTypes?: PLCDataType[] } export type PouHistory = { @@ -116,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. */