Browse Source

feat: undo and redo in the workflow editor

The canvas is changed from around forty places - adding, pasting,
deleting, connecting, auto-layout, config edits, toggling a node off.
Recording each of them by hand would mean every future change has to
remember to do the same, and the one that forgets is the one that loses
somebody's work. So the history watches the graph and records when it
settles instead of instrumenting the call sites.

What counts as a change is deliberately narrow: node ids, positions
(rounded, since a drag reports fractional pixels the whole way), name,
disabled state, config, and the wiring. Selecting a node, hovering an
edge and an arriving execution result are not edits, and an undo that
puts back a selection is worse than no undo at all.

Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y, plus toolbar buttons - an editor that
can undo and does not say so is one nobody tries it in. Both are off
while viewing an execution, where the graph is not editable anyway.

Verified in the browser against a scratch workflow: add, move, connect,
turn off and delete each undo and redo correctly, and selecting a node
is not an undoable step.
fszontagh 1 tháng trước cách đây
mục cha
commit
fd7cb15db6

+ 27 - 1
webui/src/components/workflow/EditorHeader.tsx

@@ -1,4 +1,4 @@
-import { ArrowLeft, Save, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil, Copy } from 'lucide-react'
+import { ArrowLeft, Save, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil, Copy, Undo2, Redo2} from 'lucide-react'
 import { useState, useRef, useEffect, useCallback } from 'react'
 import type { ExecutionDetail } from '../../api/workflows'
 
@@ -28,6 +28,10 @@ interface EditorHeaderProps {
   onExecuteTrigger: (triggerNodeId: string) => void
   onAddNode: () => void
   onAutoLayout: () => void
+  onUndo: () => void
+  onRedo: () => void
+  canUndo: boolean
+  canRedo: boolean
   onShowExecutionList: () => void
   onUnpinExecution: () => void
   onShowSettings: () => void
@@ -57,6 +61,10 @@ export function EditorHeader({
   onExecuteTrigger,
   onAddNode,
   onAutoLayout,
+  onUndo,
+  onRedo,
+  canUndo,
+  canRedo,
   onShowExecutionList,
   onUnpinExecution,
   onShowSettings,
@@ -176,6 +184,24 @@ export function EditorHeader({
           <Plus className="w-4 h-4" />
           Add Node
         </button>
+        {/* Shown rather than left to the shortcut: an editor that can undo and
+            does not say so is one nobody tries it in. */}
+        <button
+          onClick={onUndo}
+          disabled={isViewingExecution || !canUndo}
+          title="Undo the last change (Ctrl+Z)"
+          className="flex items-center gap-2 px-2 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+        >
+          <Undo2 className="w-4 h-4" />
+        </button>
+        <button
+          onClick={onRedo}
+          disabled={isViewingExecution || !canRedo}
+          title="Redo (Ctrl+Shift+Z)"
+          className="flex items-center gap-2 px-2 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+        >
+          <Redo2 className="w-4 h-4" />
+        </button>
         <button
           onClick={onAutoLayout}
           className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"

+ 137 - 0
webui/src/hooks/useGraphHistory.ts

@@ -0,0 +1,137 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Edge, Node } from 'reactflow'
+
+/**
+ * Undo and redo for the canvas.
+ *
+ * The graph is changed from around forty places - adding, pasting, deleting,
+ * connecting, auto-layout, config edits, toggling a node off. Recording each of
+ * them by hand would mean every future change has to remember to do the same,
+ * and the one that forgets is the one that loses somebody's work. So this
+ * watches the graph instead and records when it settles.
+ *
+ * What counts as a change is deliberately narrow. Selecting a node, hovering an
+ * edge, an execution result arriving - none of those are edits, and an undo that
+ * puts back a selection is worse than no undo at all. Only the shape of the
+ * workflow is compared: which nodes exist, where they are, what they are called,
+ * what they are configured with, whether they are off, and what is wired to what.
+ */
+
+const LIMIT = 50
+
+// Long enough that a paste, an auto-layout or a burst of typing lands as one
+// step rather than several, short enough not to be lost on a quick undo.
+const SETTLE_MS = 300
+
+export interface GraphSnapshot {
+  nodes: Node[]
+  edges: Edge[]
+}
+
+function signature(nodes: Node[], edges: Edge[]): string {
+  const nodePart = nodes
+    .map((n) =>
+      [
+        n.id,
+        n.data?.type,
+        // Rounded, because a drag reports fractional pixels the whole way and
+        // half a pixel is not something anyone wants to undo to.
+        Math.round(n.position?.x ?? 0),
+        Math.round(n.position?.y ?? 0),
+        n.data?.label,
+        n.data?.disabled === true ? 'off' : 'on',
+        JSON.stringify(n.data?.config ?? {}),
+      ].join('')
+    )
+    .sort()
+    .join('')
+
+  const edgePart = edges
+    .map((e) => [e.source, e.sourceHandle, e.target, e.targetHandle].join(''))
+    .sort()
+    .join('')
+
+  return `${nodePart}${edgePart}`
+}
+
+export function useGraphHistory(
+  nodes: Node[],
+  edges: Edge[],
+  setNodes: (nodes: Node[]) => void,
+  setEdges: (edges: Edge[]) => void,
+  enabled: boolean
+) {
+  const past = useRef<GraphSnapshot[]>([])
+  const future = useRef<GraphSnapshot[]>([])
+
+  // The last state that settled. Undo needs the state *before* a change, and by
+  // the time a change is noticed the state after it is what is in hand.
+  const settled = useRef<GraphSnapshot>({ nodes: [], edges: [] })
+  const settledSignature = useRef<string | null>(null)
+
+  // Restoring changes the graph, which would otherwise be recorded as a fresh
+  // edit - and a fresh edit throws away everything redo would have replayed.
+  const restoring = useRef(false)
+
+  const [depth, setDepth] = useState({ undo: 0, redo: 0 })
+  const publish = () => setDepth({ undo: past.current.length, redo: future.current.length })
+
+  useEffect(() => {
+    if (!enabled) return
+
+    // Mid-drag the position changes every frame. The drop is the edit.
+    if (nodes.some((n) => (n as any).dragging)) return
+
+    const current = signature(nodes, edges)
+    if (current === settledSignature.current) return
+
+    const timer = setTimeout(() => {
+      if (restoring.current) {
+        restoring.current = false
+      } else if (settledSignature.current !== null) {
+        // The very first settled state is the baseline, not an edit - there is
+        // nothing before it to go back to.
+        past.current.push(settled.current)
+        if (past.current.length > LIMIT) past.current.shift()
+        future.current = []
+        publish()
+      }
+
+      settled.current = { nodes, edges }
+      settledSignature.current = current
+    }, SETTLE_MS)
+
+    return () => clearTimeout(timer)
+  }, [nodes, edges, enabled])
+
+  const step = useCallback(
+    (from: GraphSnapshot[], to: GraphSnapshot[]) => {
+      const target = from.pop()
+      if (!target) return false
+
+      to.push({ nodes, edges })
+      restoring.current = true
+      settled.current = target
+      settledSignature.current = null
+      setNodes(target.nodes)
+      setEdges(target.edges)
+      publish()
+      return true
+    },
+    [nodes, edges, setNodes, setEdges]
+  )
+
+  const undo = useCallback(() => step(past.current, future.current), [step])
+  const redo = useCallback(() => step(future.current, past.current), [step])
+
+  // Opening a different workflow is not something to undo into.
+  const reset = useCallback(() => {
+    past.current = []
+    future.current = []
+    settled.current = { nodes: [], edges: [] }
+    settledSignature.current = null
+    publish()
+  }, [])
+
+  return { undo, redo, reset, canUndo: depth.undo > 0, canRedo: depth.redo > 0 }
+}

+ 32 - 1
webui/src/pages/WorkflowEditorPage.tsx

@@ -48,6 +48,7 @@ import { WorkflowSettingsModal } from '../components/workflow/WorkflowSettingsMo
 import { NodeConfigModal } from '../components/workflow/NodeConfigModal'
 import { copyNodesToClipboard, readNodeClipboard, offsetFor } from '../utils/nodeClipboard'
 import { takenNodeNames, uniqueNodeName } from '../utils/nodeNames'
+import { useGraphHistory } from '../hooks/useGraphHistory'
 
 interface SelectedNodeData {
   id: string
@@ -503,6 +504,12 @@ function WorkflowEditorInner() {
     }, 3000)
   }, [])
 
+  // Watched rather than wired into every place that changes the graph. See the
+  // hook for why. Off while viewing an execution, where the canvas is a
+  // read-only picture of something that already happened.
+  const history = useGraphHistory(nodes, edges, setNodes, setEdges, !isViewingExecution)
+
+
   // Get current workflow nodes and connections for pinning
   const getCurrentWorkflowData = useCallback(() => {
     const currentNodes: WorkflowNodeType[] = nodes.map((node) => ({
@@ -1663,6 +1670,26 @@ function WorkflowEditorInner() {
           setNodes((nds) => nds.map((n) => ({ ...n, selected: true })))
           return
         }
+        // Ctrl+Shift+Z and Ctrl+Y both redo, because both are what people
+        // reach for and neither is used for anything else here.
+        if (key === 'z' && e.shiftKey) {
+          e.preventDefault()
+          if (history.redo()) { setHasChanges(true); showToast('success', 'Redone') }
+          else showToast('error', 'Nothing to redo')
+          return
+        }
+        if (key === 'z') {
+          e.preventDefault()
+          if (history.undo()) { setHasChanges(true); showToast('success', 'Undone') }
+          else showToast('error', 'Nothing left to undo')
+          return
+        }
+        if (key === 'y') {
+          e.preventDefault()
+          if (history.redo()) { setHasChanges(true); showToast('success', 'Redone') }
+          else showToast('error', 'Nothing to redo')
+          return
+        }
       }
 
       if (showNodePicker) return
@@ -1708,7 +1735,7 @@ function WorkflowEditorInner() {
 
     window.addEventListener('keydown', handleKeyDown)
     return () => window.removeEventListener('keydown', handleKeyDown)
-  }, [hasChanges, saveMutation.isPending, handleSave, focusedNodeId, selectedEdgeId, showNodeConfig, showNodePicker, showDeleteConfirm, deleteEdge, openNodeConfig, nodes, edges, setNodes, setEdges, copySelection, pasteNodes, isViewingExecution])
+  }, [hasChanges, saveMutation.isPending, handleSave, focusedNodeId, selectedEdgeId, showNodeConfig, showNodePicker, showDeleteConfirm, deleteEdge, openNodeConfig, nodes, edges, setNodes, setEdges, copySelection, pasteNodes, isViewingExecution, history, showToast])
 
   // Navigate to connected node based on arrow direction
   // Handles nodes with multiple outputs (IF: true/false, Loop: loop/done)
@@ -2662,6 +2689,10 @@ function WorkflowEditorInner() {
         onExecuteTrigger={executeTrigger}
         onAddNode={() => setShowNodePicker(true)}
         onAutoLayout={autoLayout}
+        onUndo={() => { if (history.undo()) setHasChanges(true) }}
+        onRedo={() => { if (history.redo()) setHasChanges(true) }}
+        canUndo={history.canUndo}
+        canRedo={history.canRedo}
         onShowExecutionList={() => setShowExecutionList(true)}
         onUnpinExecution={unpinExecution}
         onDuplicate={() => {