From 1a459c695b1344eb912332673f64c03c1e401f7a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:55:19 +0000 Subject: [PATCH] fix: per-tab undo/redo history, stale closure in node ops, zoom animation 1. Per-tab history: Changed reducer from single HistoryState to Record keyed by tab ID. Undo/redo now only affects the active tab. Creating a new tab initializes its own history; closing a tab cleans up its history. 2. Stale closure fix: onDropComponent, onConnect, deleteSelectedNodes, and duplicateSelectedNodes now use setTransactionTabs directly to read both nodes and edges from the same state snapshot, preventing inconsistent history entries. 3. Zoom animation: handleZoomIn/handleZoomOut now use .then() on the zoomIn()/zoomOut() promises to read the zoom level after animation completes, preventing stale zoom display. Co-Authored-By: Nakamoto, S --- src/App.tsx | 141 ++++++++++++++++++++++---------------- src/components/Canvas.tsx | 6 +- 2 files changed, 84 insertions(+), 63 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 267d1cf..11986f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,18 +66,23 @@ export default function App() { })); }, [activeTransactionId]); - // Undo/redo — combined into single state to prevent index/entries divergence + // Undo/redo — per-tab history to prevent cross-tab state corruption type HistoryState = { entries: HistoryEntry[]; index: number }; + type PerTabHistoryState = Record; type HistoryAction = - | { type: 'push'; nodes: Node[]; edges: Edge[] } - | { type: 'undo' } - | { type: 'redo' } - | { type: 'reset' }; + | { type: 'push'; tabId: string; nodes: Node[]; edges: Edge[] } + | { type: 'undo'; tabId: string } + | { type: 'redo'; tabId: string } + | { type: 'reset'; tabId: string } + | { type: 'remove'; tabId: string }; - const historyReducer = useCallback((state: HistoryState, action: HistoryAction): HistoryState => { + const defaultHistory: HistoryState = { entries: [{ nodes: [], edges: [] }], index: 0 }; + + const historyReducer = useCallback((state: PerTabHistoryState, action: HistoryAction): PerTabHistoryState => { + const tabState = state[action.tabId] || defaultHistory; switch (action.type) { case 'push': { - const trimmed = state.entries.slice(0, state.index + 1); + const trimmed = tabState.entries.slice(0, tabState.index + 1); const entry = { nodes: JSON.parse(JSON.stringify(action.nodes)), edges: JSON.parse(JSON.stringify(action.edges)) }; const next = [...trimmed, entry]; let newIndex = trimmed.length; @@ -85,44 +90,51 @@ export default function App() { next.shift(); newIndex = next.length - 1; } - return { entries: next, index: newIndex }; + return { ...state, [action.tabId]: { entries: next, index: newIndex } }; } case 'undo': { - if (state.index <= 0) return state; - return { ...state, index: state.index - 1 }; + if (tabState.index <= 0) return state; + return { ...state, [action.tabId]: { ...tabState, index: tabState.index - 1 } }; } case 'redo': { - if (state.index >= state.entries.length - 1) return state; - return { ...state, index: state.index + 1 }; + if (tabState.index >= tabState.entries.length - 1) return state; + return { ...state, [action.tabId]: { ...tabState, index: tabState.index + 1 } }; } case 'reset': { - return { entries: [{ nodes: [], edges: [] }], index: 0 }; + return { ...state, [action.tabId]: { entries: [{ nodes: [], edges: [] }], index: 0 } }; + } + case 'remove': { + const { [action.tabId]: _, ...rest } = state; + return rest; } } }, []); - const [historyState, dispatchHistory] = useReducer(historyReducer, { entries: [{ nodes: [], edges: [] }], index: 0 }); - const history = historyState.entries; - const historyIndex = historyState.index; - const pushHistory = useCallback((n: Node[], e: Edge[]) => { - dispatchHistory({ type: 'push', nodes: n, edges: e }); + const [historyState, dispatchHistory] = useReducer(historyReducer, { 'tx-1': { entries: [{ nodes: [], edges: [] }], index: 0 } }); + const activeHistory = historyState[activeTransactionId] || defaultHistory; + const history = activeHistory.entries; + const historyIndex = activeHistory.index; + const pushHistory = useCallback((tabId: string, n: Node[], e: Edge[]) => { + dispatchHistory({ type: 'push', tabId, nodes: n, edges: e }); }, []); const undo = useCallback(() => { - const entry = historyState.entries[historyState.index - 1]; - if (!entry || historyState.index <= 0) return; - dispatchHistory({ type: 'undo' }); + const tabHistory = historyState[activeTransactionId] || defaultHistory; + const entry = tabHistory.entries[tabHistory.index - 1]; + if (!entry || tabHistory.index <= 0) return; + dispatchHistory({ type: 'undo', tabId: activeTransactionId }); setNodes(JSON.parse(JSON.stringify(entry.nodes))); setEdges(JSON.parse(JSON.stringify(entry.edges))); - }, [historyState, setNodes, setEdges]); + }, [historyState, activeTransactionId, setNodes, setEdges]); const redo = useCallback(() => { - const entry = historyState.entries[historyState.index + 1]; - if (!entry || historyState.index >= historyState.entries.length - 1) return; - dispatchHistory({ type: 'redo' }); + const tabHistory = historyState[activeTransactionId] || defaultHistory; + const entry = tabHistory.entries[tabHistory.index + 1]; + if (!entry || tabHistory.index >= tabHistory.entries.length - 1) return; + dispatchHistory({ type: 'redo', tabId: activeTransactionId }); setNodes(JSON.parse(JSON.stringify(entry.nodes))); setEdges(JSON.parse(JSON.stringify(entry.edges))); - }, [historyState, setNodes, setEdges]); + }, [historyState, activeTransactionId, setNodes, setEdges]); // Selected nodes const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); @@ -224,25 +236,29 @@ export default function App() { position, data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined }, }; - setNodes(prev => { - const next = [...prev, newNode]; - pushHistory(next, edges); - return next; - }); + const tabId = activeTransactionId; + setTransactionTabs(prev => prev.map(t => { + if (t.id !== tabId) return t; + const nextNodes = [...t.nodes, newNode]; + pushHistory(tabId, nextNodes, t.edges); + return { ...t, nodes: nextNodes }; + })); addRecentComponent(item.id); addTerminalEntry('info', 'canvas', `Node added: ${item.label}`); addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`); - }, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]); + }, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]); const onConnect = useCallback((params: Connection) => { - setEdges(prev => { - const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev); - pushHistory(nodes, next); - return next; - }); + const tabId = activeTransactionId; + setTransactionTabs(prev => prev.map(t => { + if (t.id !== tabId) return t; + const nextEdges = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, t.edges); + pushHistory(tabId, t.nodes, nextEdges); + return { ...t, edges: nextEdges }; + })); addTerminalEntry('info', 'canvas', `Edge connected: ${params.source} → ${params.target}`); addAuditEntry('EDGE_CREATE', `Connection created`); - }, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]); + }, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]); const onNodesChange = useCallback((changes: NodeChange[]) => { setNodes((prev: Node[]) => applyNodeChanges(changes, prev)); @@ -258,34 +274,38 @@ export default function App() { const deleteSelectedNodes = useCallback(() => { if (selectedNodeIds.size === 0) return; - setNodes(prev => { - const next = prev.filter(n => !selectedNodeIds.has(n.id)); - pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target))); - return next; - }); - setEdges(prev => prev.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target))); + const tabId = activeTransactionId; + setTransactionTabs(prev => prev.map(t => { + if (t.id !== tabId) return t; + const nextNodes = t.nodes.filter(n => !selectedNodeIds.has(n.id)); + const nextEdges = t.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)); + pushHistory(tabId, nextNodes, nextEdges); + return { ...t, nodes: nextNodes, edges: nextEdges }; + })); addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`); addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`); setSelectedNodeIds(new Set()); - }, [selectedNodeIds, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]); + }, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]); const duplicateSelectedNodes = useCallback(() => { if (selectedNodeIds.size === 0) return; - const selected = nodes.filter(n => selectedNodeIds.has(n.id)); - const newNodes = selected.map(n => ({ - ...n, - id: `node_${nodeIdCounter.current++}`, - position: { x: n.position.x + 40, y: n.position.y + 40 }, - selected: false, + const tabId = activeTransactionId; + setTransactionTabs(prev => prev.map(t => { + if (t.id !== tabId) return t; + const selected = t.nodes.filter(n => selectedNodeIds.has(n.id)); + const newNodes = selected.map(n => ({ + ...n, + id: `node_${nodeIdCounter.current++}`, + position: { x: n.position.x + 40, y: n.position.y + 40 }, + selected: false, + })); + const nextNodes = [...t.nodes, ...newNodes]; + pushHistory(tabId, nextNodes, t.edges); + return { ...t, nodes: nextNodes }; })); - setNodes(prev => { - const next = [...prev, ...newNodes]; - pushHistory(next, edges); - return next; - }); - addTerminalEntry('info', 'canvas', `Duplicated ${selected.length} node(s)`); - addAuditEntry('NODE_DUPLICATE', `Duplicated ${selected.length} node(s)`); - }, [selectedNodeIds, nodes, setNodes, edges, pushHistory, addTerminalEntry, addAuditEntry]); + addTerminalEntry('info', 'canvas', `Duplicated node(s)`); + addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`); + }, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]); // Validate const runValidation = useCallback(() => { @@ -378,13 +398,14 @@ export default function App() { const id = `tx-${Date.now()}`; setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]); setActiveTransactionId(id); - dispatchHistory({ type: 'reset' }); + dispatchHistory({ type: 'reset', tabId: id }); addTerminalEntry('info', 'system', 'New transaction tab created'); }, [addTerminalEntry]); const closeTransactionTab = useCallback((id: string) => { if (transactionTabs.length <= 1) return; setTransactionTabs(prev => prev.filter(t => t.id !== id)); + dispatchHistory({ type: 'remove', tabId: id }); if (activeTransactionId === id) { const remaining = transactionTabs.filter(t => t.id !== id); setActiveTransactionId(remaining[0].id); diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 03c61e8..ad243fc 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -58,7 +58,7 @@ interface CanvasProps { onCloseTab: (id: string) => void; splitView: boolean; onToggleSplitView: () => void; - pushHistory: (nodes: Node[], edges: Edge[]) => void; + pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void; } function CanvasInner({ @@ -105,8 +105,8 @@ function CanvasInner({ setZoomLevel(Math.round(zoom * 100)); }, [reactFlowInstance]); - const handleZoomIn = () => { reactFlowInstance.zoomIn(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); }; - const handleZoomOut = () => { reactFlowInstance.zoomOut(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); }; + const handleZoomIn = () => { reactFlowInstance.zoomIn().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); }; + const handleZoomOut = () => { reactFlowInstance.zoomOut().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); }; const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 100); }; const errorCount = nodes.filter(n => (n.data as Record).status === 'error').length;