import { Background, BackgroundVariant, type Connection, type Node as FlowCanvasNode, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, useStore, useUpdateNodeInternals, } from "@xyflow/react" import "@xyflow/react/dist/style.css" import { useMutation, useQuery, useQueryClient, useSuspenseQuery, } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Workflow } from "lucide-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { type FlowDef_Input, type FlowDetail, FlowsService, type MessageSpec, type NodeDef_Input, } from "@/client" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import useCustomToast from "@/hooks/useCustomToast" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { CanvasTitle } from "./CanvasTitle" import { CommandPalette } from "./CommandPalette" import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EndpointNode } from "./EndpointNode" import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode, placementsFor, rememberPlacement, } from "./endpoints" import { FIT_VIEW, FlowDock } from "./FlowDock" import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" import { LiveEdge } from "./LiveEdge" import { NodePanel } from "./NodePanel" import "./flow.css" import { liveStore, useFlowPaused } from "./liveStore" import { flowKeys, flowQueryOptions, flowsQueryOptions, nodeTypesQueryOptions, useAutosave, useDiscardDraft, usePublish, } from "./queries" import { useFlowSocket } from "./useFlowSocket" const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode } /** Is this canvas node actually part of the flow document? */ const isDocumentNode = (node: { id: string }) => !isEndpointNode(node) const edgeTypes = { live: LiveEdge } type Rebind = { nodeId: string nodeLabel: string port: string from: string to: string dtype: MessageSpec["dtype"] } /** A finished message rename, waiting on an answer about the rest of the flow. */ type MessageRename = { from: string to: string /** How many other nodes are still bound to the old name. */ count: number } /** Documents to step back and forward through, newest last. */ type History = { past: FlowDef_Input[] future: FlowDef_Input[] /** When the last entry was recorded, so a burst of typing stays one edit. */ at: number } const HISTORY_LIMIT = 50 const TYPING_WINDOW = 500 /** Nodes copied here, and the code of the ones carrying their own. */ type NodeClipboard = { nodes: NodeDef_Input[] sources: Record } // ponytail: localStorage, so a copy survives a flow switch or a second tab but // not a second browser. The system clipboard if that ever matters. const CLIPBOARD_KEY = "fluksio.nodeClipboard" /** * Remember the document as it was before a change. * * Fields commit on every keystroke, so consecutive edits that leave the same * nodes in place fold into the entry already on the stack. Anything carrying * positions — a drag, a new node — is a finished action and starts its own. */ function record( history: History, previous: FlowDef_Input, next: FlowDef_Input, settled: boolean, ) { const before = previous.nodes ?? [] const after = next.nodes ?? [] const sameNodes = before.length === after.length && before.every((node, index) => node.id === after[index].id) const stillTyping = !settled && sameNodes && Date.now() - history.at < TYPING_WINDOW if (!stillTyping) { history.past.push(previous) if (history.past.length > HISTORY_LIMIT) history.past.shift() } history.at = settled ? 0 : Date.now() // A new change is a new branch: what was undone is not coming back. history.future = [] } function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] { return definitions.map((node) => ({ id: node.id, type: "flow", position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 }, data: {}, })) } /** * The dot grid lives in flow space, so React Flow shrinks it along with the * zoom. Below 1:1 that leaves sub-pixel dots on a fractional grid, which the * canvas can only render as a moiré haze — the whole viewport reads as blurry. * Taking the zoom back out of both the radius and the spacing gives the same * crisp 1.5px dots, 24px apart, whatever the zoom is. */ function CanvasBackground() { const zoom = useStore((state) => state.transform[2]) // In octaves, so the grid halves rather than drifting as you zoom. const step = 2 ** Math.round(Math.log2(1 / zoom)) return ( ) } /** Step a new node off any node already sitting at that spot. */ function freePosition( nodes: NodeDef_Input[], start: { x: number; y: number }, ): { x: number; y: number } { const position = { ...start } // Roughly a node's footprint, so a nudged node clears the one below it. const occupied = () => nodes.some( (node) => Math.abs((node.position?.x ?? 0) - position.x) < 220 && Math.abs((node.position?.y ?? 0) - position.y) < 80, ) while (occupied()) { position.x += 48 position.y += 96 } return position } /** A name that does not collide with the nodes already on the canvas. */ function uniqueNodeId(existing: NodeDef_Input[], type: string): string { const taken = new Set(existing.map((node) => node.id)) for (let index = 1; ; index += 1) { const candidate = index === 1 ? type : `${type}_${index}` if (!taken.has(candidate)) return candidate } } function FlowEditorInner({ flowName, onReload, }: { flowName: string onReload: () => void }) { const navigate = useNavigate() const queryClient = useQueryClient() const { showErrorToast, showSuccessToast } = useCustomToast() const { screenToFlowPosition, fitView } = useReactFlow() const updateNodeInternals = useUpdateNodeInternals() const { data: flows } = useSuspenseQuery(flowsQueryOptions()) const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName)) const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions()) const { save, flush, conflict, resolveConflict, mutation: saving, } = useAutosave(flowName) const publish = usePublish(flowName) const discard = useDiscardDraft(flowName) // The whole working document, so a flow-level edit is an edit like any // other — undoable, and on screen before the server has seen it. const [flowDoc, setFlowDoc] = useState(() => detail.definition) const definitions = useMemo(() => flowDoc.nodes ?? [], [flowDoc]) const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes( detail.definition.nodes ?? [], ) const [selectedId, setSelectedId] = useState(null) const [paletteOpen, setPaletteOpen] = useState(false) const [inspected, setInspected] = useState(null) const [rebind, setRebind] = useState(null) const [renamed, setRenamed] = useState(null) const [flowPanelOpen, setFlowPanelOpen] = useState(false) // The editor at full size takes the width the canvas chrome does not need. const [editorExpanded, setEditorExpanded] = useState(false) // The dock hosts the logs, but a failing node opens them too, at its own // lines — so which node and whether it is open belong here. const [logsOpen, setLogsOpen] = useState(false) const [logsNode, setLogsNode] = useState(null) const issues = detail.issues ?? [] const paused = useFlowPaused(flowName) // Keep the latest document in a ref so autosave never captures a stale copy. const latest = useRef(detail.definition) const history = useRef({ past: [], future: [], at: 0 }) /** Put a document on the canvas and on its way to the server. */ const applyDoc = useCallback( (next: FlowDef_Input) => { setFlowDoc(next) latest.current = next save(next) }, [save], ) /** Apply a change to the whole document and make it undoable. */ const commitDoc = useCallback( (next: FlowDef_Input, settled = false) => { record(history.current, latest.current, next, settled) applyDoc(next) }, [applyDoc], ) /** The same, for the changes that only touch the nodes. */ const commit = useCallback( (nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => { const placed = nodes.map((node) => { const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id) return canvas ? { ...node, position: canvas.position } : node }) commitDoc({ ...latest.current, nodes: placed }, Boolean(positions)) }, [canvasNodes, commitDoc], ) /** * Step through the history. Undo and redo save like any other edit, so the * autosave debounce collapses a run of them into one write. */ const step = useCallback( (back: boolean) => { const { past, future } = history.current const remembered = (back ? past : future).pop() if (!remembered) return ;(back ? future : past).push(latest.current) history.current.at = 0 // xyflow owns the positions, so hand it the remembered ones too. setCanvasNodes(toCanvasNodes(remembered.nodes ?? [])) applyDoc(remembered) setInspected(null) }, [applyDoc, setCanvasNodes], ) const typeLabels = useMemo( () => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])), [nodeTypeInfo], ) // Which types bring code of their own, so a copy has something to carry. const sourceTypes = useMemo( () => new Set( (nodeTypeInfo ?? []) .filter((info) => info.has_source) .map((info) => info.type), ), [nodeTypeInfo], ) // Which types came from an installed connector rather than the engine. They // cannot be in the icon map, so they share one. const pluginTypes = useMemo( () => new Set( (nodeTypeInfo ?? []) .filter((info) => info.plugin) .map((info) => info.type), ), [nodeTypeInfo], ) const issuesByNode = useMemo(() => { const map = new Map() for (const issue of issues) { if (!issue.node) continue const list = map.get(issue.node) ?? [] list.push(issue.message) map.set(issue.node, list) } return map }, [issues]) const showLogs = useCallback((nodeId: string) => { setLogsNode(nodeId) setLogsOpen(true) }, []) // Canvas nodes carry the definition so the node component can render it. const renderedNodes = useMemo( () => canvasNodes.map((node) => { const definition = definitions.find((entry) => entry.id === node.id) const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? [] return { ...node, selected: node.id === selectedId, data: { definition: definition ?? { id: node.id }, flow: flowName, typeLabel: typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", isPlugin: pluginTypes.has(definition?.type ?? ""), issueText: nodeIssues.join("\n"), onShowLogs: showLogs, } satisfies FlowNodeData, } }), [ canvasNodes, definitions, flowName, issuesByNode, pluginTypes, selectedId, showLogs, typeLabels, ], ) // A cheap fingerprint of the wiring: it changes when a name does, but not // when a node merely moves. const key = bindingsKey(definitions) // Offer the names already in play: everything published is worth reading, // and an input nobody provides yet is worth publishing. // biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes names. const suggestions = useMemo(() => { const provided = new Set() const consumed = new Set() for (const node of definitions) { for (const spec of node.provides ?? []) { if (spec.name) provided.add(spec.name) } for (const spec of node.requires ?? []) { if (spec.name) consumed.add(spec.name) } } return { consumes: [...provided].sort(), provides: [...consumed].filter((name) => !provided.has(name)).sort(), } }, [key]) // Endpoints are movable but are not the flow's to store, so where they were // put lives in the browser rather than in flow.json. const [moved, setMoved] = useState>( () => placementsFor(flowName), ) /** Where clicking an endpoint takes you: the thing it stands for. */ const openEndpoint = useCallback( (id: string) => { const [kind, rest] = id.split(":", 2) if (kind === "dashboard") { navigate({ to: "/dashboards/$name", params: { name: (rest ?? "").split(":")[0] }, }) } else if (kind === "flow") { navigate({ to: "/flows/$flowName", params: { flowName: (rest ?? "").split(".")[0] }, }) } }, [navigate], ) // Dashboards and other flows wired into this one. They are drawn but never // stored: they join at render, after everything that reads or writes // canvasNodes, so an autosave, an undo or a delete cannot reach them. // biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring. const external = useMemo( () => deriveEndpoints( detail.endpoints ?? [], definitions, flowName, new Map(canvasNodes.map((node) => [node.id, node.position])), moved, ), [detail.endpoints, key, flowName, moved], ) // Edges follow from the name bindings, so they are derived, never stored. // Kept off `external` deliberately: an endpoint's edges depend on which // messages it touches, never on where it sits, so dragging one must not // rebuild the edge array on every frame. // biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame. const edges = useMemo( () => [...deriveEdges(definitions, flowName), ...external.edges], [key, flowName, detail.endpoints], ) const shownNodes = useMemo( () => [...renderedNodes, ...external.nodes], [renderedNodes, external], ) // Editing ports adds and removes handles. React Flow measures those once, so // it has to be told, or an edge to a brand-new handle never gets drawn. // biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles. useEffect(() => { updateNodeInternals(definitions.map((node) => node.id)) }, [key, updateNodeInternals]) const runMutation = useMutation({ mutationFn: () => FlowsService.runFlow({ name: flowName, requestBody: { inputs: {} } }), onSuccess: (state) => { liveStore.setValues( Object.fromEntries( Object.entries(state.values ?? {}).map(([name, value]) => [ name, { value: value.value, ts: value.ts ?? null }, ]), ), ) }, onError: () => showErrorToast("The flow could not run. Check the node errors."), }) const enableMutation = useMutation({ mutationFn: (next: boolean) => next ? FlowsService.startFlow({ name: flowName }) : FlowsService.stopFlow({ name: flowName }), onSuccess: (detail) => { queryClient.setQueryData(flowKeys.detail(flowName), detail) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, onError: () => showErrorToast("The flow could not be started or stopped."), }) const pauseMutation = useMutation({ mutationFn: (next: boolean) => next ? FlowsService.pauseFlow({ name: flowName }) : FlowsService.resumeFlow({ name: flowName }), // The engine answers with a flow_paused event, which is what the dock reads. onError: () => showErrorToast("The flow could not be paused."), }) const stepMutation = useMutation({ mutationFn: () => FlowsService.stepFlow({ name: flowName }), // Says which node ran, or that nothing was held back — both are answers. onSuccess: (result) => showSuccessToast(result.message), onError: () => showErrorToast("The flow could not be stepped."), }) const deleteMutation = useMutation({ mutationFn: () => FlowsService.deleteFlow({ name: flowName }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: flowKeys.all }) setFlowPanelOpen(false) navigate({ to: "/flows", replace: true }) }, onError: () => showErrorToast("The flow could not be deleted."), }) const sourceMutation = useMutation({ mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) => FlowsService.saveNodeSource({ name: flowName, nodeId, requestBody: { code }, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["flows", flowName] }) }, }) const addNode = useCallback( (type: string, sourceRef?: string) => { const id = uniqueNodeId(definitions, sourceRef ?? type) // Drop it where the user is looking, but never on top of another node. const position = freePosition( definitions, screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2, }), ) const node: NodeDef_Input = { id, type, position, params: {}, requires: [], provides: [], // A shared node brings the code; the ports and settings are this // flow's own. ...(sourceRef ? { source_ref: sourceRef } : {}), } const nextDefinitions = [...definitions, node] const nextCanvas = [ ...canvasNodes, { id, type: "flow", position, data: {} } as FlowCanvasNode, ] setCanvasNodes(nextCanvas) commit(nextDefinitions, nextCanvas) setSelectedId(id) }, [canvasNodes, commit, definitions, screenToFlowPosition, setCanvasNodes], ) const updateNode = useCallback( (next: NodeDef_Input) => { commit(definitions.map((node) => (node.id === next.id ? next : node))) }, [commit, definitions], ) const deleteNodes = useCallback( (ids: string[]) => { const remaining = definitions.filter((node) => !ids.includes(node.id)) setCanvasNodes(canvasNodes.filter((node) => !ids.includes(node.id))) commit(remaining) if (selectedId && ids.includes(selectedId)) setSelectedId(null) }, [canvasNodes, commit, definitions, selectedId, setCanvasNodes], ) const applyBinding = useCallback( (nodeId: string, port: string, message: string) => { commit( definitions.map((node) => node.id === nodeId ? { ...node, requires: (node.requires ?? []).map((spec) => portOf(spec) === port ? { ...spec, name: message, port } : spec, ), } : node, ), ) }, [commit, definitions], ) /** * Dragging output to input is shorthand for "consume what that node * publishes": it points the input at the producer's message name. */ const onConnect = useCallback( (connection: Connection) => { const producer = definitions.find((node) => node.id === connection.source) const consumer = definitions.find((node) => node.id === connection.target) if (!producer || !consumer) return const outSpec = (producer.provides ?? []).find( (spec) => portOf(spec) === connection.sourceHandle, ) const inSpec = (consumer.requires ?? []).find( (spec) => portOf(spec) === connection.targetHandle, ) if (!outSpec?.name || !inSpec) return // Already reading something else: the user may want either message, so // offer the extra port rather than assuming a replacement. if (inSpec.name && inSpec.name !== outSpec.name) { setRebind({ nodeId: consumer.id, nodeLabel: consumer.title || consumer.id, port: portOf(inSpec), from: inSpec.name, to: outSpec.name, dtype: outSpec.dtype, }) return } applyBinding(consumer.id, portOf(inSpec), outSpec.name) }, [definitions, applyBinding], ) /** Give the consumer a second input, bound to the producer's message. */ const addInputPort = useCallback( (nodeId: string, message: string, dtype: MessageSpec["dtype"]) => { commit( definitions.map((node) => node.id === nodeId ? { ...node, requires: [ ...(node.requires ?? []), { name: message, port: "", dtype }, ], } : node, ), ) }, [commit, definitions], ) const unbind = useCallback( (message: string) => { const qualified = qualify(flowName, message) commit( definitions.map((node) => ({ ...node, requires: (node.requires ?? []).map((spec) => qualify(flowName, spec.name ?? "") === qualified ? { ...spec, name: "", port: portOf(spec) } : spec, ), })), ) setInspected(null) }, [commit, definitions, flowName], ) /** * A message name is shared, not owned: renaming it where it is published * strands every node still reading the old one. Offer to bring them along * rather than deciding for the user. */ const proposeRename = useCallback( (from: string, to: string) => { if (!from || !to) return const message = qualify(flowName, from) const bound = definitions.filter((node) => [...(node.requires ?? []), ...(node.provides ?? [])].some( (spec) => qualify(flowName, spec.name ?? "") === message, ), ) if (bound.length) setRenamed({ from, to, count: bound.length }) }, [definitions, flowName], ) /** Carry a rename to everything bound to the old name, as one edit. */ const applyRename = useCallback( ({ from, to }: MessageRename) => { const message = qualify(flowName, from) const follow = (specs: MessageSpec[] | undefined) => (specs ?? []).map((spec) => qualify(flowName, spec.name ?? "") === message ? { ...spec, name: to, port: "" } : spec, ) commit( definitions.map((node) => ({ ...node, requires: follow(node.requires), provides: follow(node.provides), })), ) setRenamed(null) }, [commit, definitions, flowName], ) const focusNode = useCallback( (qualifiedId: string) => { const id = qualifiedId.startsWith(`${flowName}.`) ? qualifiedId.slice(flowName.length + 1) : qualifiedId fitView({ nodes: [{ id }], duration: 300, maxZoom: 1.2 }) setSelectedId(id) }, [fitView, flowName], ) /** Put the stored draft live. Publishing what is queued means saving first. */ const publishFlow = useCallback(async () => { await flush() // Publish what was actually stored: the version only advances once the // queued save has landed. const current = queryClient.getQueryData( flowKeys.detail(flowName), ) publish.mutate(current?.definition.version ?? 1) }, [flowName, flush, publish.mutate, queryClient]) /** Copy the selected nodes, with the code of the ones carrying their own. */ const copyNodes = useCallback(async () => { const ids = new Set( canvasNodes.filter((node) => node.selected).map((node) => node.id), ) if (selectedId) ids.add(selectedId) const picked = definitions.filter((node) => ids.has(node.id)) if (!picked.length) return const sources: Record = {} await Promise.all( picked // A shared node already points at the library copy; only a private // one has code that has to travel with it. .filter((node) => !node.source_ref && sourceTypes.has(node.type ?? "")) .map(async (node) => { const { code } = await FlowsService.readNodeSource({ name: flowName, nodeId: node.id, }) sources[node.id] = code }), ).catch(() => undefined) localStorage.setItem( CLIPBOARD_KEY, JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard), ) }, [canvasNodes, definitions, flowName, selectedId, sourceTypes]) /** * Paste them here, renamed around whatever this flow already holds. The * document only names a node's code, so the copy is written per node once * the definition is on its way. */ const pasteNodes = useCallback(() => { const stored = localStorage.getItem(CLIPBOARD_KEY) if (!stored) return let clipboard: NodeClipboard try { clipboard = JSON.parse(stored) } catch { return } let pool = definitions const pasted: NodeDef_Input[] = [] for (const node of clipboard.nodes ?? []) { const position = freePosition(pool, { // Offset, so a copy of a node in this flow is visibly its own. x: (node.position?.x ?? 0) + 48, y: (node.position?.y ?? 0) + 48, }) const copy = { ...node, id: uniqueNodeId(pool, node.id), position } pool = [...pool, copy] pasted.push(copy) } if (!pasted.length) return const nextCanvas = [ ...canvasNodes, ...pasted.map( (node) => ({ id: node.id, type: "flow", position: node.position, data: {}, }) as FlowCanvasNode, ), ] setCanvasNodes(nextCanvas) commit(pool, nextCanvas) setSelectedId(pasted[pasted.length - 1].id) clipboard.nodes.forEach((node, index) => { const code = clipboard.sources?.[node.id] if (code !== undefined) { sourceMutation.mutate({ nodeId: pasted[index].id, code }) } }) }, [canvasNodes, commit, definitions, setCanvasNodes, sourceMutation.mutate]) useShortcuts( { "mod+z": () => step(true), "mod+shift+z": () => step(false), "mod+c": () => void copyNodes(), "mod+v": pasteNodes, "mod+k": () => setPaletteOpen((open) => !open), // Inside the code editor ⌘S applies that code, which the node panel // owns; anywhere else on the canvas it puts the flow live. "mod+s": (event) => { if (!inCodeEditor(event.target)) void publishFlow() }, }, // Both stay reachable while typing: one is the editor's own save, the // other is how you reach anything at all. ["mod+s", "mod+k"], ) const selected = definitions.find((node) => node.id === selectedId) ?? null // A panel is the view you are working in, so it takes the room — but never // the lanes the bars sit in: publishing is most wanted right after editing. const panelOpen = Boolean(selected) || flowPanelOpen return ( <> { // An endpoint's position is ours, not React Flow's, so it only // follows the pointer if we move it every frame. const endpoints = dragged.filter(isEndpointNode) if (!endpoints.length) return setMoved((current) => { const next = { ...current } for (const node of endpoints) next[node.id] = node.position return next }) }} onNodeDragStop={(_event, _node, dragged) => { for (const node of dragged.filter(isEndpointNode)) { // Written once at the end; every frame would be a write per pixel. rememberPlacement(flowName, node.id, node.position) } const own = dragged.filter(isDocumentNode) if (own.length) commit(definitions, mergeDragged(canvasNodes, own)) }} onNodesDelete={(deleted) => deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) } onNodeClick={(_event, node) => { // An endpoint is somewhere else's: opening its panel here would // offer to edit a node this flow does not contain. if (!isDocumentNode(node)) { openEndpoint(node.id) return } setFlowPanelOpen(false) setSelectedId(node.id) }} onPaneClick={() => { // Clicking the canvas is how you put a panel away, whichever one it // is: the graph is what you went back to look at. setSelectedId(null) setFlowPanelOpen(false) setEditorExpanded(false) setInspected(null) }} onEdgeClick={(event, edge) => { const label = (id: string) => { const node = definitions.find((entry) => entry.id === id) return node?.title || node?.id || id } setInspected({ message: (edge.data as { message: string }).message, from: label(edge.source), to: label(edge.target), sourceId: edge.source, x: event.clientX, y: event.clientY, }) }} onConnect={onConnect} nodeTypes={nodeTypes} edgeTypes={edgeTypes} proOptions={{ hideAttribution: true }} fitView fitViewOptions={FIT_VIEW} minZoom={0.25} maxZoom={2} nodeDragThreshold={5} connectionRadius={30} connectOnClick autoPanOnConnect edgesReconnectable={false} deleteKeyCode={["Backspace", "Delete"]} className="h-full w-full" > {/* * The bars keep their own lane rather than making way for a panel: they * carry Publish, which is what you reach for the moment a node is done. * The floating panel is inset out of that lane instead. */}
{flowDoc.title || flowName} { setSelectedId(null) setFlowPanelOpen(true) }} onPublish={() => void publishFlow()} logs={{ open: logsOpen, node: logsNode, onOpenChange: (open) => { setLogsOpen(open) if (!open) setLogsNode(null) }, onClearNode: () => setLogsNode(null), }} onAddNode={() => setPaletteOpen(true)} onRun={async () => { // Running executes what is stored, so the queued edit goes first. await flush() runMutation.mutate() }} onTogglePause={() => pauseMutation.mutate(!paused)} onStep={() => stepMutation.mutate()} stepping={stepMutation.isPending} onFocusNode={focusNode} />
{definitions.length === 0 ? (

This flow is empty

Add a node to get started. Press ⌘K, or use the plus in the bar below.

) : null} commitDoc({ ...next, nodes: definitions }, true)} onDelete={() => deleteMutation.mutate()} enabled={detail.enabled ?? true} toggling={enableMutation.isPending} onToggleEnabled={(next) => enableMutation.mutate(next)} hasDraft={detail.has_draft ?? false} discarding={discard.isPending} onDiscardDraft={() => { discard.mutate(undefined, { // The published document replaces what is on the canvas, and the // version counter goes back with it. onSuccess: () => { setFlowPanelOpen(false) onReload() }, }) }} onClose={() => setFlowPanelOpen(false)} /> setEditorExpanded((wide) => !wide)} onChange={updateNode} onRenameMessage={proposeRename} onSaveSource={(code) => { if (selected) sourceMutation.mutate({ nodeId: selected.id, code }) }} // Sharing rewrites the stored document, so the canvas takes the // server's copy rather than keeping its own. onShared={onReload} onClose={() => { flush() setEditorExpanded(false) setSelectedId(null) }} onDelete={() => selected && deleteNodes([selected.id])} /> setInspected(null)} onUnbind={unbind} /> addNode("python", libName)} onRun={() => runMutation.mutate()} /> !open && setRebind(null)} > How should {rebind?.nodeLabel} read this? Its "{rebind?.port}" input already reads{" "} {rebind?.from}. It can take{" "} {rebind?.to} as well, or instead.
!open && setRenamed(null)} > Rename this message everywhere? {renamed?.count === 1 ? "One other node is" : `${renamed?.count} other nodes are`}{" "} still bound to {renamed?.from}. They keep the old name unless they follow it to{" "} {renamed?.to}. {/* * Not dismissable: until one version wins, every further save fails, so * there is nothing useful to go back to. */} event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} > Someone else changed this flow Another editor saved {flowName}{" "} while you were working on it. Load their version, or keep yours and write over theirs. ) } function mergeDragged( nodes: FlowCanvasNode[], dragged: FlowCanvasNode[], ): FlowCanvasNode[] { const moved = new Map(dragged.map((node) => [node.id, node.position])) return nodes.map((node) => moved.has(node.id) ? { ...node, position: moved.get(node.id)! } : node, ) } /** Seed xyflow's own node state once; it owns positions while you drag. */ function useUnpositionedNodes(definitions: NodeDef_Input[]) { return useNodesState(toCanvasNodes(definitions)) } export function FlowEditor({ flowName }: { flowName: string }) { const navigate = useNavigate() const onAuthFailure = useCallback(() => { navigate({ to: "/login" }) }, [navigate]) // Bumped when the canvas has to take the server's document over its own: // local edits live in state seeded on mount, so a remount is the reset. const [epoch, setEpoch] = useState(0) const reload = useCallback(() => setEpoch((n) => n + 1), []) useFlowSocket(onAuthFailure) return ( {/* * Remounting per flow keeps canvas state from leaking between them, and * it is what makes `fitView` run once per flow: xyflow queues the fit on * mount and resolves it as soon as the nodes have been measured. */} ) }