|
@@ -32,7 +32,7 @@ import { AlertTriangle } from 'lucide-react'
|
|
|
import { wsClient } from '../api/client'
|
|
import { wsClient } from '../api/client'
|
|
|
import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
|
|
import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
|
|
|
import { useWorkflowLocks } from '../hooks/useWorkflowLocks'
|
|
import { useWorkflowLocks } from '../hooks/useWorkflowLocks'
|
|
|
-import { diffWorkflows, mergeWorkflows, summariseChanges, type Change } from '../utils/workflowDiff'
|
|
|
|
|
|
|
+import { diffWorkflows, graphDelta, mergeWorkflows, summariseChanges, type Change } from '../utils/workflowDiff'
|
|
|
import type { AvailableField } from '../components/ConditionBuilder'
|
|
import type { AvailableField } from '../components/ConditionBuilder'
|
|
|
import type { FieldInfo } from '../components/AvailableDataPanel'
|
|
import type { FieldInfo } from '../components/AvailableDataPanel'
|
|
|
import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
|
|
import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
|
|
@@ -591,10 +591,6 @@ function WorkflowEditorInner() {
|
|
|
const loadedVersionRef = useRef<number | null>(null)
|
|
const loadedVersionRef = useRef<number | null>(null)
|
|
|
const { others: otherViewers, myOtherTabs, savedByOther, dismissSavedByOther } =
|
|
const { others: otherViewers, myOtherTabs, savedByOther, dismissSavedByOther } =
|
|
|
useWorkflowPresence(id, loadedVersionRef)
|
|
useWorkflowPresence(id, loadedVersionRef)
|
|
|
- const {
|
|
|
|
|
- lockedByOther, livePositions, claim: claimNode, release: releaseNode,
|
|
|
|
|
- reportMove, deniedNode, dismissDenied,
|
|
|
|
|
- } = useWorkflowLocks(id)
|
|
|
|
|
// The save handler needs the latest value without being rebuilt for it.
|
|
// The save handler needs the latest value without being rebuilt for it.
|
|
|
const savedByOtherRef = useRef(savedByOther)
|
|
const savedByOtherRef = useRef(savedByOther)
|
|
|
savedByOtherRef.current = savedByOther
|
|
savedByOtherRef.current = savedByOther
|
|
@@ -1339,6 +1335,61 @@ function WorkflowEditorInner() {
|
|
|
return map
|
|
return map
|
|
|
}, [nodeDefs])
|
|
}, [nodeDefs])
|
|
|
|
|
|
|
|
|
|
+ // What every editor watching this workflow should currently have. Edits are
|
|
|
|
|
+ // sent as the difference from this, and it moves forward both when this
|
|
|
|
|
+ // canvas broadcasts and when somebody else's edit is applied.
|
|
|
|
|
+ const sharedGraphRef = useRef<any>(null)
|
|
|
|
|
+ const applyingRemoteRef = useRef(false)
|
|
|
|
|
+
|
|
|
|
|
+ const applyRemoteEdit = useCallback((edit: any) => {
|
|
|
|
|
+ applyingRemoteRef.current = true
|
|
|
|
|
+
|
|
|
|
|
+ if (edit.changed?.length || edit.removed?.length) {
|
|
|
|
|
+ const removed = new Set<string>(edit.removed ?? [])
|
|
|
|
|
+ const changedById = new Map<string, any>((edit.changed ?? []).map((n: any) => [n.id, n]))
|
|
|
|
|
+ setNodes((current) => {
|
|
|
|
|
+ const kept = current
|
|
|
|
|
+ .filter((n) => !removed.has(n.id))
|
|
|
|
|
+ .map((n) => {
|
|
|
|
|
+ const incoming = changedById.get(n.id)
|
|
|
|
|
+ if (!incoming) return n
|
|
|
|
|
+ changedById.delete(n.id)
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...n,
|
|
|
|
|
+ position: incoming.position ?? n.position,
|
|
|
|
|
+ data: {
|
|
|
|
|
+ ...n.data,
|
|
|
|
|
+ label: incoming.name ?? n.data.label,
|
|
|
|
|
+ config: incoming.config ?? n.data.config,
|
|
|
|
|
+ disabled: incoming.disabled === true,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ // Whatever is left is new to this canvas. Built the same way as a node
|
|
|
|
|
+ // that arrives from a load, so it behaves like any other.
|
|
|
|
|
+ const added = [...changedById.values()].map((n: any) =>
|
|
|
|
|
+ makeNodeFromStored(n, nodeDefsMap, executeWorkflow, executeTrigger, executionState)
|
|
|
|
|
+ )
|
|
|
|
|
+ return [...kept, ...added]
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (edit.connections) {
|
|
|
|
|
+ setEdges(() => buildEdgesFromStored(edit.connections))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof edit.name === 'string') setWorkflowName(edit.name)
|
|
|
|
|
+
|
|
|
|
|
+ // Their change is now an unsaved change here too, and saying otherwise
|
|
|
|
|
+ // would let it be lost on the way out.
|
|
|
|
|
+ setHasChanges(true)
|
|
|
|
|
+ }, [setNodes, setEdges, nodeDefsMap, executionState])
|
|
|
|
|
+
|
|
|
|
|
+ const {
|
|
|
|
|
+ lockedByOther, livePositions, claim: claimNode, release: releaseNode,
|
|
|
|
|
+ reportMove, broadcastEdit, deniedNode, dismissDenied,
|
|
|
|
|
+ } = useWorkflowLocks(id, applyRemoteEdit)
|
|
|
|
|
+
|
|
|
// Compute viewed nodes and edges when in view mode (from workflow snapshot)
|
|
// Compute viewed nodes and edges when in view mode (from workflow snapshot)
|
|
|
const { viewedNodes, viewedEdges } = useMemo(() => {
|
|
const { viewedNodes, viewedEdges } = useMemo(() => {
|
|
|
if (!isViewingExecution || !pinnedExecution?.workflowSnapshot) {
|
|
if (!isViewingExecution || !pinnedExecution?.workflowSnapshot) {
|
|
@@ -1422,60 +1473,12 @@ function WorkflowEditorInner() {
|
|
|
// Initialize nodes and edges from workflow
|
|
// Initialize nodes and edges from workflow
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (workflow && nodeDefs.length > 0) {
|
|
if (workflow && nodeDefs.length > 0) {
|
|
|
- const flowNodes: Node[] = workflow.nodes.map((node: any) => {
|
|
|
|
|
- const nodeDef = nodeDefsMap[node.type]
|
|
|
|
|
- const outputs: NodeOutput[] = nodeDef?.outputs?.length
|
|
|
|
|
- ? nodeDef.outputs
|
|
|
|
|
- : [{ name: 'main', displayName: 'Output', type: 'any' }]
|
|
|
|
|
- return {
|
|
|
|
|
- id: node.id,
|
|
|
|
|
- type: 'workflowNode',
|
|
|
|
|
- position: node.position || { x: 0, y: 0 },
|
|
|
|
|
- data: {
|
|
|
|
|
- label: node.name || node.type,
|
|
|
|
|
- type: node.type,
|
|
|
|
|
- config: node.config,
|
|
|
|
|
- isTrigger: nodeDef?.isTrigger || false,
|
|
|
|
|
- icon: nodeDef?.icon,
|
|
|
|
|
- outputs,
|
|
|
|
|
- dynamicOutputs: nodeDef?.dynamicOutputs,
|
|
|
|
|
- inputs: nodeDef?.inputs,
|
|
|
|
|
- nodeId: node.id,
|
|
|
|
|
- disabled: node.disabled === true,
|
|
|
|
|
- onExecute: () => executeWorkflow(),
|
|
|
|
|
- onExecuteTrigger: executeTrigger,
|
|
|
|
|
- executionState: executionState.nodeStates[node.id],
|
|
|
|
|
- },
|
|
|
|
|
- selected: false,
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- const flowEdges: Edge[] = workflow.connections.map((conn: any, idx: number) => {
|
|
|
|
|
- let edgeColor = '#cbd5e1' // Lighter default for better visibility
|
|
|
|
|
- if (conn.sourceOutput === 'true' || conn.sourceOutput === 'loop') {
|
|
|
|
|
- edgeColor = '#22c55e'
|
|
|
|
|
- } else if (conn.sourceOutput === 'false') {
|
|
|
|
|
- edgeColor = '#ef4444'
|
|
|
|
|
- } else if (conn.sourceOutput === 'done') {
|
|
|
|
|
- edgeColor = '#3b82f6'
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- id: `e${idx}`,
|
|
|
|
|
- source: conn.sourceNodeId,
|
|
|
|
|
- sourceHandle: conn.sourceOutput,
|
|
|
|
|
- target: conn.targetNodeId,
|
|
|
|
|
- targetHandle: conn.targetInput,
|
|
|
|
|
- type: 'smart',
|
|
|
|
|
- style: { strokeWidth: 2, stroke: edgeColor },
|
|
|
|
|
- animated: executionState.status === 'running',
|
|
|
|
|
- label: conn.sourceOutput !== 'main' ? conn.sourceOutput : undefined,
|
|
|
|
|
- labelStyle: { fill: edgeColor, fontWeight: 500, fontSize: 10 },
|
|
|
|
|
- labelBgStyle: { fill: 'var(--color-bg)', fillOpacity: 0.9 },
|
|
|
|
|
- data: { handleType: conn.sourceOutput || 'main' },
|
|
|
|
|
- zIndex: 1,
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const flowNodes: Node[] = workflow.nodes.map((node: any) =>
|
|
|
|
|
+ makeNodeFromStored(node, nodeDefsMap, () => executeWorkflow(), executeTrigger, executionState)
|
|
|
|
|
+ )
|
|
|
|
|
+ const flowEdges: Edge[] = buildEdgesFromStored(
|
|
|
|
|
+ workflow.connections, executionState.status === 'running'
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
setNodes(flowNodes)
|
|
setNodes(flowNodes)
|
|
|
setEdges(flowEdges)
|
|
setEdges(flowEdges)
|
|
@@ -1487,9 +1490,53 @@ function WorkflowEditorInner() {
|
|
|
setHasChanges(loadedIsUnsavedRef.current)
|
|
setHasChanges(loadedIsUnsavedRef.current)
|
|
|
loadedIsUnsavedRef.current = false
|
|
loadedIsUnsavedRef.current = false
|
|
|
setGraphEpoch((n) => n + 1)
|
|
setGraphEpoch((n) => n + 1)
|
|
|
|
|
+ // Everybody who loads this record already agrees on it, so it is the
|
|
|
|
|
+ // point future edits are measured from.
|
|
|
|
|
+ sharedGraphRef.current = {
|
|
|
|
|
+ name: workflow.name,
|
|
|
|
|
+ nodes: workflow.nodes || [],
|
|
|
|
|
+ connections: workflow.connections || [],
|
|
|
|
|
+ settings: workflow.settings || {},
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}, [workflow, nodeDefs, nodeDefsMap, setNodes, setEdges])
|
|
}, [workflow, nodeDefs, nodeDefsMap, setNodes, setEdges])
|
|
|
|
|
|
|
|
|
|
+ // Send what changed here to everybody else watching.
|
|
|
|
|
+ //
|
|
|
|
|
+ // Watching the graph rather than calling this from each place that changes it
|
|
|
|
|
+ // is the same choice the undo history makes, and for the same reason: there
|
|
|
|
|
+ // are around forty such places, and the one that forgets is the one whose
|
|
|
|
|
+ // edits silently never leave the browser. An auto-layout, a config change, a
|
|
|
|
|
+ // delete and an undo are all just the graph looking different afterwards.
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (isViewingExecution || !id) return
|
|
|
|
|
+ // A drag has its own stream, several times a second; this would only send
|
|
|
|
|
+ // the same thing again more slowly.
|
|
|
|
|
+ if (nodes.some((n) => (n as any).dragging)) return
|
|
|
|
|
+
|
|
|
|
|
+ const timer = setTimeout(() => {
|
|
|
|
|
+ const now = currentGraphRef.current()
|
|
|
|
|
+
|
|
|
|
|
+ // An edit that arrived from somebody else must not be echoed back to
|
|
|
|
|
+ // them, or two editors will talk in circles.
|
|
|
|
|
+ if (applyingRemoteRef.current) {
|
|
|
|
|
+ applyingRemoteRef.current = false
|
|
|
|
|
+ sharedGraphRef.current = now
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!sharedGraphRef.current) {
|
|
|
|
|
+ sharedGraphRef.current = now
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const delta = graphDelta(sharedGraphRef.current, now)
|
|
|
|
|
+ if (delta) {
|
|
|
|
|
+ broadcastEdit(delta)
|
|
|
|
|
+ sharedGraphRef.current = now
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 200)
|
|
|
|
|
+ return () => clearTimeout(timer)
|
|
|
|
|
+ }, [nodes, edges, workflowName, id, isViewingExecution, broadcastEdit])
|
|
|
|
|
+
|
|
|
// Put the lock state and anybody else's in-flight drag onto the canvas.
|
|
// Put the lock state and anybody else's in-flight drag onto the canvas.
|
|
|
// Separate from loading, because these arrive while somebody is working and
|
|
// Separate from loading, because these arrive while somebody is working and
|
|
|
// must not disturb anything else about the node.
|
|
// must not disturb anything else about the node.
|
|
@@ -3305,6 +3352,77 @@ function WorkflowEditorInner() {
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * A stored node or connection turned into something ReactFlow can draw.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Pulled out of the loader because a live edit arriving from another editor has
|
|
|
|
|
+ * to build a node exactly the same way. Two copies of this would drift, and the
|
|
|
|
|
+ * symptom would be a node that behaves subtly differently depending on whether
|
|
|
|
|
+ * it was loaded or received.
|
|
|
|
|
+ */
|
|
|
|
|
+function makeNodeFromStored(
|
|
|
|
|
+ node: any,
|
|
|
|
|
+ nodeDefsMap: Record<string, NodeDefinition>,
|
|
|
|
|
+ onExecute: () => void,
|
|
|
|
|
+ onExecuteTrigger: (nodeId: string) => void,
|
|
|
|
|
+ executionState: { nodeStates: Record<string, any> }
|
|
|
|
|
+): Node {
|
|
|
|
|
+ const nodeDef = nodeDefsMap[node.type]
|
|
|
|
|
+ const outputs: NodeOutput[] = nodeDef?.outputs?.length
|
|
|
|
|
+ ? nodeDef.outputs
|
|
|
|
|
+ : [{ name: 'main', displayName: 'Output', type: 'any' }]
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: node.id,
|
|
|
|
|
+ type: 'workflowNode',
|
|
|
|
|
+ position: node.position || { x: 0, y: 0 },
|
|
|
|
|
+ data: {
|
|
|
|
|
+ label: node.name || node.type,
|
|
|
|
|
+ type: node.type,
|
|
|
|
|
+ config: node.config,
|
|
|
|
|
+ isTrigger: nodeDef?.isTrigger || false,
|
|
|
|
|
+ icon: nodeDef?.icon,
|
|
|
|
|
+ outputs,
|
|
|
|
|
+ dynamicOutputs: nodeDef?.dynamicOutputs,
|
|
|
|
|
+ inputs: nodeDef?.inputs,
|
|
|
|
|
+ nodeId: node.id,
|
|
|
|
|
+ disabled: node.disabled === true,
|
|
|
|
|
+ onExecute,
|
|
|
|
|
+ onExecuteTrigger,
|
|
|
|
|
+ executionState: executionState.nodeStates[node.id],
|
|
|
|
|
+ },
|
|
|
|
|
+ selected: false,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildEdgesFromStored(connections: any[], animated = false): Edge[] {
|
|
|
|
|
+ return (connections || []).map((conn: any, idx: number) => {
|
|
|
|
|
+ let edgeColor = '#cbd5e1'
|
|
|
|
|
+ if (conn.sourceOutput === 'true' || conn.sourceOutput === 'loop') {
|
|
|
|
|
+ edgeColor = '#22c55e'
|
|
|
|
|
+ } else if (conn.sourceOutput === 'false') {
|
|
|
|
|
+ edgeColor = '#ef4444'
|
|
|
|
|
+ } else if (conn.sourceOutput === 'done') {
|
|
|
|
|
+ edgeColor = '#3b82f6'
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: `e${idx}`,
|
|
|
|
|
+ source: conn.sourceNodeId,
|
|
|
|
|
+ sourceHandle: conn.sourceOutput,
|
|
|
|
|
+ target: conn.targetNodeId,
|
|
|
|
|
+ targetHandle: conn.targetInput,
|
|
|
|
|
+ type: 'smart',
|
|
|
|
|
+ style: { strokeWidth: 2, stroke: edgeColor },
|
|
|
|
|
+ animated,
|
|
|
|
|
+ label: conn.sourceOutput !== 'main' ? conn.sourceOutput : undefined,
|
|
|
|
|
+ labelStyle: { fill: edgeColor, fontWeight: 500, fontSize: 10 },
|
|
|
|
|
+ labelBgStyle: { fill: 'var(--color-bg)', fillOpacity: 0.9 },
|
|
|
|
|
+ data: { handleType: conn.sourceOutput || 'main' },
|
|
|
|
|
+ zIndex: 1,
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export default function WorkflowEditorPage() {
|
|
export default function WorkflowEditorPage() {
|
|
|
return (
|
|
return (
|
|
|
<ReactFlowProvider>
|
|
<ReactFlowProvider>
|