Skip to content

Commit 162792c

Browse files
authored
Merge pull request #178 from ShipSecAI/betterclever/undo-redo-implementation
feat(workflow): implement undo/redo functionality
2 parents 34e473a + 7f614ec commit 162792c

File tree

14 files changed

+1080
-165
lines changed

14 files changed

+1080
-165
lines changed

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"xterm": "^5.3.0",
115115
"xterm-addon-fit": "^0.8.0",
116116
"zod": "^4.1.11",
117+
"zundo": "^2.3.0",
117118
"zustand": "^5.0.8",
118119
},
119120
"devDependencies": {
@@ -2810,6 +2811,8 @@
28102811

28112812
"zod-validation-error": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
28122813

2814+
"zundo": ["[email protected]", "", { "peerDependencies": { "zustand": "^4.3.0 || ^5.0.0" } }, "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ=="],
2815+
28132816
"zustand": ["[email protected]", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="],
28142817

28152818
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"xterm": "^5.3.0",
8080
"xterm-addon-fit": "^0.8.0",
8181
"zod": "^4.1.11",
82+
"zundo": "^2.3.0",
8283
"zustand": "^5.0.8"
8384
}
8485
}

frontend/src/components/layout/TopBar.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
Loader2,
1515
Pencil,
1616
MoreHorizontal,
17+
Undo2,
18+
Redo2,
1719
} from 'lucide-react'
1820
import {
1921
DropdownMenu,
@@ -34,6 +36,10 @@ interface TopBarProps {
3436
onImport?: (file: File) => Promise<void> | void
3537
onExport?: () => void
3638
canManageWorkflows?: boolean
39+
onUndo?: () => void
40+
onRedo?: () => void
41+
canUndo?: boolean
42+
canRedo?: boolean
3743
}
3844

3945
const DEFAULT_WORKFLOW_NAME = 'Untitled Workflow'
@@ -46,6 +52,10 @@ export function TopBar({
4652
onImport,
4753
onExport,
4854
canManageWorkflows = true,
55+
onUndo,
56+
onRedo,
57+
canUndo,
58+
canRedo,
4959
}: TopBarProps) {
5060
const navigate = useNavigate()
5161
const [isSaving, setIsSaving] = useState(false)
@@ -318,6 +328,28 @@ export function TopBar({
318328
<div className="flex items-center gap-1 md:gap-2">
319329
{mode === 'design' && (
320330
<>
331+
<div className="hidden md:flex items-center gap-0.5 border-r border-border/50 pr-2 mr-1">
332+
<Button
333+
variant="ghost"
334+
size="icon"
335+
className="h-8 w-8"
336+
onClick={onUndo}
337+
disabled={!canEdit || !canUndo}
338+
title="Undo (⌘Z)"
339+
>
340+
<Undo2 className="h-4 w-4" />
341+
</Button>
342+
<Button
343+
variant="ghost"
344+
size="icon"
345+
className="h-8 w-8"
346+
onClick={onRedo}
347+
disabled={!canEdit || !canRedo}
348+
title="Redo (⌘⇧Z)"
349+
>
350+
<Redo2 className="h-4 w-4" />
351+
</Button>
352+
</div>
321353
{(onImport || onExport) && (
322354
<div className="hidden md:flex items-center gap-1.5 sm:gap-2">
323355
{onImport && (

frontend/src/components/workflow/Canvas.tsx

Lines changed: 56 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ interface EntryPointActionsContextValue {
6262
const EntryPointActionsContext = createContext<EntryPointActionsContextValue>({})
6363
export const useEntryPointActions = () => useContext(EntryPointActionsContext)
6464

65-
const MAX_DELETE_HISTORY = 10
6665
const ENTRY_COMPONENT_ID = 'core.workflow.entrypoint'
6766
const ENTRY_COMPONENT_SLUG = 'entry-point'
6867

@@ -76,10 +75,7 @@ const isEntryPointNode = (node?: Node<NodeData> | null) => {
7675
return isEntryPointComponentRef(componentRef)
7776
}
7877

79-
interface DeleteHistoryEntry {
80-
nodes: Node<NodeData>[]
81-
edges: Edge[]
82-
}
78+
8379

8480
interface CanvasProps {
8581
className?: string
@@ -102,6 +98,7 @@ interface CanvasProps {
10298
onCloseScheduleSidebar?: () => void
10399
onClearNodeSelection?: () => void
104100
onNodeSelectionChange?: (node: Node<NodeData> | null) => void
101+
onSnapshot?: (nodes?: Node<NodeData>[], edges?: Edge[]) => void
105102
}
106103

107104
export function Canvas({
@@ -125,6 +122,7 @@ export function Canvas({
125122
onCloseScheduleSidebar,
126123
onClearNodeSelection,
127124
onNodeSelectionChange,
125+
onSnapshot,
128126
}: CanvasProps) {
129127
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
130128
const [selectedNode, setSelectedNode] = useState<Node<NodeData> | null>(null)
@@ -155,8 +153,9 @@ export function Canvas({
155153
const resolvedOnOpenScheduleSidebar = onOpenScheduleSidebar ?? scheduleContext?.onOpenScheduleSidebar
156154
const resolvedOnCloseScheduleSidebar = onCloseScheduleSidebar ?? scheduleContext?.onCloseScheduleSidebar
157155
const applyEdgesChange = onEdgesChange
158-
const deleteHistoryRef = useRef<DeleteHistoryEntry[]>([])
156+
159157
const hasUserInteractedRef = useRef(false)
158+
const snapshotDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
160159
const prevModeRef = useRef<typeof mode>(mode)
161160
const prevNodesLengthRef = useRef(nodes.length)
162161
const prevEdgesLengthRef = useRef(edges.length)
@@ -309,36 +308,44 @@ export function Canvas({
309308
isHighlighted: selectedNodeId === params.source || selectedNodeId === params.target,
310309
},
311310
}
312-
setEdges((eds) => addEdge(newEdge, eds))
311+
312+
// Calculate new edges
313+
const newEdges = addEdge(newEdge, edges)
314+
setEdges(newEdges)
315+
316+
// Calculate new nodes (if input mapping update is needed)
317+
let nextNodes = nodes
313318

314319
// Update target node's input mapping
315320
if (params.target && params.targetHandle && params.source && params.sourceHandle) {
316321
const targetHandle = params.targetHandle
317-
setNodes((nds) =>
318-
nds.map((node) =>
319-
node.id === params.target
320-
? {
321-
...node,
322-
data: {
323-
...node.data,
324-
inputs: {
325-
...(node.data.inputs as Record<string, unknown>),
326-
[targetHandle]: {
327-
source: params.source,
328-
output: params.sourceHandle,
329-
},
330-
} as Record<string, unknown>,
331-
},
332-
}
333-
: node
334-
)
322+
nextNodes = nodes.map((node) =>
323+
node.id === params.target
324+
? {
325+
...node,
326+
data: {
327+
...node.data,
328+
inputs: {
329+
...(node.data.inputs as Record<string, unknown>),
330+
[targetHandle]: {
331+
source: params.source,
332+
output: params.sourceHandle,
333+
},
334+
} as Record<string, unknown>,
335+
},
336+
}
337+
: node
335338
)
339+
setNodes(nextNodes)
336340
}
337341

342+
// Capture snapshot for history
343+
onSnapshot?.(nextNodes, newEdges)
344+
338345
// Mark workflow as dirty
339346
markDirty()
340347
},
341-
[setEdges, setNodes, nodes, edges, getComponent, markDirty, mode, toast]
348+
[setEdges, setNodes, nodes, edges, getComponent, markDirty, mode, toast, selectedNodeId, onSnapshot]
342349
)
343350

344351
// Fit view on initial load, when nodes/edges are added/removed, or when switching modes
@@ -493,7 +500,10 @@ export function Canvas({
493500
} as any,
494501
}
495502

496-
setNodes((nds) => nds.concat(newNode))
503+
// Update nodes and capture snapshot
504+
const nextNodes = nodes.concat(newNode)
505+
setNodes(nextNodes)
506+
onSnapshot?.(nextNodes, edges)
497507

498508
// Analytics: node added
499509
try {
@@ -507,7 +517,7 @@ export function Canvas({
507517
// Mark workflow as dirty
508518
markDirty()
509519
},
510-
[reactFlowInstance, setNodes, getComponent, markDirty, mode, nodes, toast, workflowId]
520+
[reactFlowInstance, setNodes, getComponent, markDirty, mode, nodes, edges, toast, workflowId, onSnapshot]
511521
)
512522

513523
const onDrop = useCallback(
@@ -633,17 +643,27 @@ export function Canvas({
633643

634644
// Handle node data update from config panel
635645
const handleUpdateNode = useCallback((nodeId: string, data: Partial<NodeData>) => {
636-
setNodes((nds) =>
637-
nds.map((node) =>
638-
node.id === nodeId
639-
? { ...node, data: { ...node.data, ...data } }
640-
: node
641-
)
646+
const newNodes = nodes.map((node) =>
647+
node.id === nodeId
648+
? { ...node, data: { ...node.data, ...data } }
649+
: node
642650
)
643651

644-
// Mark workflow as dirty
652+
setNodes(newNodes)
653+
654+
// Debounce history snapshot to avoid creating history entries for every keystroke
655+
if (snapshotDebounceRef.current) {
656+
clearTimeout(snapshotDebounceRef.current)
657+
}
658+
659+
snapshotDebounceRef.current = setTimeout(() => {
660+
onSnapshot?.(newNodes, edges)
661+
snapshotDebounceRef.current = null
662+
}, 500)
663+
664+
// Mark workflow as dirty immediately so Save button enables
645665
markDirty()
646-
}, [setNodes, markDirty])
666+
}, [nodes, edges, setNodes, markDirty, onSnapshot])
647667

648668
// Sync selectedNode with the latest node data from nodes array
649669
useEffect(() => {
@@ -709,52 +729,7 @@ export function Canvas({
709729
return
710730
}
711731

712-
const isUndoShortcut =
713-
(event.key === 'z' || event.key === 'Z') &&
714-
(event.metaKey || event.ctrlKey) &&
715-
!event.shiftKey
716732

717-
if (isUndoShortcut) {
718-
event.preventDefault()
719-
const lastDeletion = deleteHistoryRef.current.pop()
720-
if (!lastDeletion) {
721-
return
722-
}
723-
724-
if (lastDeletion.nodes.length > 0) {
725-
setNodes((nds) => {
726-
const existingIds = new Set(nds.map((node) => node.id))
727-
const nodesToRestore = lastDeletion.nodes
728-
.filter((node) => !existingIds.has(node.id))
729-
.map((node) => ({ ...node, selected: false }))
730-
731-
if (nodesToRestore.length === 0) {
732-
return nds
733-
}
734-
735-
return nds.concat(nodesToRestore)
736-
})
737-
}
738-
739-
if (lastDeletion.edges.length > 0) {
740-
setEdges((eds) => {
741-
const existingIds = new Set(eds.map((edge) => edge.id))
742-
const edgesToRestore = lastDeletion.edges
743-
.filter((edge) => !existingIds.has(edge.id))
744-
.map((edge) => ({ ...edge, selected: false }))
745-
746-
if (edgesToRestore.length === 0) {
747-
return eds
748-
}
749-
750-
return eds.concat(edgesToRestore)
751-
})
752-
}
753-
754-
setSelectedNode(null)
755-
markDirty()
756-
return
757-
}
758733

759734
if (event.key === 'Delete' || event.key === 'Backspace') {
760735
const target = event.target
@@ -797,17 +772,7 @@ export function Canvas({
797772
dedupedEdges.set(edge.id, { ...edge, selected: false })
798773
})
799774

800-
const historyEntryNodes = selectedNodes.map((node) => ({ ...node, selected: false }))
801-
const historyEntryEdges = Array.from(dedupedEdges.values())
802775

803-
if (historyEntryNodes.length > 0 || historyEntryEdges.length > 0) {
804-
const history = deleteHistoryRef.current.slice(-(MAX_DELETE_HISTORY - 1))
805-
history.push({
806-
nodes: historyEntryNodes,
807-
edges: historyEntryEdges,
808-
})
809-
deleteHistoryRef.current = history
810-
}
811776

812777
if (selectedNodes.length > 0) {
813778
setNodes((nds) => nds.filter((node) => !nodeIds.has(node.id)))

0 commit comments

Comments
 (0)