- LiveEdge: a value inside a running pulse restarts the animation by hand, since the class is already on and would show nothing. - Dock, FlowDock: the bar is centred on left-1/2, where a shrink-wrapped box gets only the half of the screen to the right and wrapped into a 195px pill. w-max with the viewport cap keeps one row whenever the buttons fit. The dashboard editor's dock had the same squeeze. - FlowEditor: arriving on an input drawn on the canvas centres its label and opens the label popover once the view is fit, as clicking it does. An input the canvas does not draw still opens the flow settings. - Tests: the phone dock may not be confined to half the screen; the search spec reads the popover first and reaches the settings through its pencil. Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
1786 lines
60 KiB
TypeScript
1786 lines
60 KiB
TypeScript
import {
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
useSuspenseQuery,
|
|
} from "@tanstack/react-query"
|
|
import { useNavigate } from "@tanstack/react-router"
|
|
import {
|
|
Background,
|
|
BackgroundVariant,
|
|
type Connection,
|
|
type Edge,
|
|
type EdgeChange,
|
|
type Node as FlowCanvasNode,
|
|
type NodeChange,
|
|
ReactFlow,
|
|
ReactFlowProvider,
|
|
useNodesState,
|
|
useReactFlow,
|
|
useStore,
|
|
useUpdateNodeInternals,
|
|
} from "@xyflow/react"
|
|
import { Workflow } from "lucide-react"
|
|
import { motion } from "motion/react"
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
|
|
import {
|
|
type DevicePublic,
|
|
type FlowDef_Input,
|
|
type FlowDetail,
|
|
FlowsService,
|
|
type MessageSpec,
|
|
type NodeDef_Input,
|
|
RunsService,
|
|
} from "@/client"
|
|
import { DiscardDraftDialog } from "@/components/Common/Dock"
|
|
import {
|
|
DEVICE_TYPE,
|
|
deviceNodeOf,
|
|
type ReportedPort,
|
|
withPort,
|
|
} from "@/components/Devices/ports"
|
|
import { useTheme } from "@/components/theme-provider"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import useAuth from "@/hooks/useAuth"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { useIsMobile } from "@/hooks/useMobile"
|
|
import { scaleIn } from "@/lib/motion"
|
|
import { notify } from "@/lib/notificationStore"
|
|
import { safeStorage } from "@/lib/safeStorage"
|
|
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
|
import { cn } from "@/lib/utils"
|
|
import { CanvasTitle } from "./CanvasTitle"
|
|
import { CommandPalette } from "./CommandPalette"
|
|
import {
|
|
bindingsKey,
|
|
boundaryKey,
|
|
deriveEdges,
|
|
type FlowEdgeData,
|
|
flowOf,
|
|
portOf,
|
|
qualify,
|
|
} from "./deriveEdges"
|
|
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
|
import { EndpointNode } from "./EndpointNode"
|
|
import {
|
|
deriveEndpoints,
|
|
ENDPOINT_TYPE,
|
|
type EndpointNodeData,
|
|
isEndpointNode,
|
|
} from "./endpoints"
|
|
import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock"
|
|
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
|
import { FlowPanel } from "./FlowPanel"
|
|
import { InputInspector, type InspectedInput } from "./InputInspector"
|
|
import { LiveEdge } from "./LiveEdge"
|
|
import {
|
|
type Direction,
|
|
type Layout,
|
|
layoutGraph,
|
|
type NodeShape,
|
|
} from "./layout"
|
|
import { NodePanel } from "./NodePanel"
|
|
import { RunDialog } from "./RunDialog"
|
|
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 }
|
|
|
|
/**
|
|
* Where every node sits and how every edge runs, once the engine has said so.
|
|
*
|
|
* The layout is a chunk of its own and answers asynchronously, so a canvas has
|
|
* a moment before it knows where anything goes. It draws nothing at all until
|
|
* then — a route already shows a skeleton for that — and an *edit* keeps the
|
|
* arrangement it had, so only the node that changed lands a frame late rather
|
|
* than the whole graph flickering through the corner.
|
|
*/
|
|
function useLayout(
|
|
ids: string[],
|
|
edges: Edge[],
|
|
direction: Direction,
|
|
shapes: Map<string, NodeShape>,
|
|
shapeKey: string,
|
|
): Layout | null {
|
|
const [placed, setPlaced] = useState<Layout | null>(null)
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
|
|
useEffect(() => {
|
|
let stale = false
|
|
layoutGraph(ids, edges, direction, shapes).then((layout) => {
|
|
// A graph edited while the last one was still being laid out has already
|
|
// asked for another; that one is the answer, not this.
|
|
if (!stale) setPlaced(layout)
|
|
})
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [shapeKey, edges])
|
|
return placed
|
|
}
|
|
|
|
/** 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<string, string>
|
|
}
|
|
|
|
// 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 that adds
|
|
* or removes a 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 = []
|
|
}
|
|
|
|
/** Positions come from the layout, so xyflow's own state only tracks identity. */
|
|
function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
|
|
return definitions.map((node) => ({
|
|
id: node.id,
|
|
type: "flow",
|
|
position: { x: 0, 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 (
|
|
<Background
|
|
variant={BackgroundVariant.Dots}
|
|
gap={24 * step}
|
|
size={1.5 / zoom}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Whether arriving on this id selects it. A node does, and so does another
|
|
* flow's label, which is where a link from that flow lands; every other
|
|
* endpoint opens what it stands for instead.
|
|
*/
|
|
function landsOn(id: string): boolean {
|
|
return !isEndpointNode({ id }) || id.startsWith("flow:")
|
|
}
|
|
|
|
/** 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,
|
|
focus,
|
|
onReload,
|
|
}: {
|
|
flowName: string
|
|
/** A node or endpoint to arrive on, from the address bar. */
|
|
focus?: string
|
|
onReload: () => void
|
|
}) {
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const { showErrorToast, showSuccessToast } = useCustomToast()
|
|
const { fitView } = useReactFlow()
|
|
const updateNodeInternals = useUpdateNodeInternals()
|
|
const { resolvedTheme } = useTheme()
|
|
|
|
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
|
|
const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName))
|
|
const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions())
|
|
|
|
const {
|
|
save,
|
|
flush,
|
|
conflict,
|
|
resolveConflict,
|
|
adopt,
|
|
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<FlowDef_Input>(() => detail.definition)
|
|
const definitions = useMemo(() => flowDoc.nodes ?? [], [flowDoc])
|
|
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
|
|
detail.definition.nodes ?? [],
|
|
)
|
|
// An endpoint is never selected; arriving on one opens it instead (below).
|
|
// Another flow's label is the exception: it is where a link from that flow
|
|
// lands, so it is selected like a node.
|
|
const [selectedId, setSelectedId] = useState<string | null>(
|
|
focus && landsOn(focus) ? focus : null,
|
|
)
|
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
|
|
const [inspectedInput, setInspectedInput] = useState<InspectedInput | null>(
|
|
null,
|
|
)
|
|
// The edges are derived, so xyflow's own selection would be thrown away on
|
|
// every rebuild: the ids live here instead and are marked on at render.
|
|
const [selectedEdges, setSelectedEdges] = useState<ReadonlySet<string>>(
|
|
() => new Set(),
|
|
)
|
|
const [rebind, setRebind] = useState<Rebind | null>(null)
|
|
const [renamed, setRenamed] = useState<MessageRename | null>(null)
|
|
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
|
|
// A batch run is asked for its parameters before it is submitted.
|
|
const [runOpen, setRunOpen] = useState(false)
|
|
// Throwing an edit away is offered from the dock, so its confirmation lives
|
|
// here rather than inside the settings panel.
|
|
const [discardOpen, setDiscardOpen] = 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<string | null>(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<FlowDef_Input>(detail.definition)
|
|
const history = useRef<History>({ 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[]) => {
|
|
commitDoc({ ...latest.current, nodes })
|
|
},
|
|
[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 run code of their own, a source or a command. Adding one is a
|
|
// superuser's, so nobody else is offered one.
|
|
const { user } = useAuth()
|
|
const mayEditCode = Boolean(user?.is_superuser)
|
|
const lockedTypes = useMemo(
|
|
() =>
|
|
new Set(
|
|
mayEditCode
|
|
? []
|
|
: (nodeTypeInfo ?? [])
|
|
.filter((info) => info.runs_code)
|
|
.map((info) => info.type),
|
|
),
|
|
[nodeTypeInfo, mayEditCode],
|
|
)
|
|
|
|
// 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, warningsByNode] = useMemo(() => {
|
|
const faults = new Map<string, string[]>()
|
|
const warnings = new Map<string, string[]>()
|
|
for (const issue of issues) {
|
|
// A node's dot and border are its status channel. An advisory is not a
|
|
// fault, so it says its piece in the dock rather than painting the node
|
|
// red. A degraded node, a device that is offline, is the one warning
|
|
// the node itself wears, in amber.
|
|
if (!issue.node) continue
|
|
const map = !issue.advisory
|
|
? faults
|
|
: issue.code === "node_degraded"
|
|
? warnings
|
|
: null
|
|
if (!map) continue
|
|
const list = map.get(issue.node) ?? []
|
|
list.push(issue.message)
|
|
map.set(issue.node, list)
|
|
}
|
|
return [faults, warnings]
|
|
}, [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}`) ?? []
|
|
const nodeWarnings = warningsByNode.get(`${flowName}.${node.id}`) ?? []
|
|
return {
|
|
...node,
|
|
selected: 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"),
|
|
warningText: nodeWarnings.join("\n"),
|
|
onShowLogs: showLogs,
|
|
} satisfies FlowNodeData,
|
|
}
|
|
}),
|
|
[
|
|
canvasNodes,
|
|
definitions,
|
|
flowName,
|
|
issuesByNode,
|
|
warningsByNode,
|
|
pluginTypes,
|
|
selectedId,
|
|
showLogs,
|
|
typeLabels,
|
|
],
|
|
)
|
|
|
|
// A cheap fingerprint of the wiring, and the only thing the layout depends
|
|
// on: what the graph looks like follows from what is wired to what. The
|
|
// flow's own boundary is drawn too, so a declared input counts as wiring.
|
|
const key = `${bindingsKey(definitions)}|${boundaryKey(flowDoc)}`
|
|
|
|
// 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<string>()
|
|
const consumed = new Set<string>()
|
|
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])
|
|
|
|
// What the flow's nodes read, for a device node to say that a camera read
|
|
// here sends all the time.
|
|
const readers = useMemo(() => {
|
|
const found = new Set<string>()
|
|
for (const node of definitions) {
|
|
if (node.type === DEVICE_TYPE) continue
|
|
for (const spec of node.requires ?? []) {
|
|
if (spec.name) found.add(qualify(flowName, spec.name))
|
|
}
|
|
}
|
|
return found
|
|
}, [definitions, flowName])
|
|
|
|
/**
|
|
* Where clicking an endpoint takes you: the thing it stands for.
|
|
*
|
|
* `via` is the node here it is wired to. The other flow draws that node as
|
|
* a label of its own, which is where following the link lands.
|
|
*/
|
|
const openEndpoint = useCallback(
|
|
(id: string, via?: string) => {
|
|
const [kind, rest] = id.split(":", 2)
|
|
if (kind === "input" || kind === "output") {
|
|
// This flow's own boundary, declared in its settings.
|
|
setSelectedId(null)
|
|
setFlowPanelOpen(true)
|
|
} else 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] },
|
|
search: { node: `flow:${via ? `${flowName}.${via}` : flowName}` },
|
|
})
|
|
}
|
|
},
|
|
[navigate, flowName],
|
|
)
|
|
|
|
/**
|
|
* Read an input where its label sits: what it is declared as and what it
|
|
* holds now. With no label to anchor to it opens the flow settings, where
|
|
* inputs are declared.
|
|
*/
|
|
const inspectInput = useCallback(
|
|
(id: string) => {
|
|
const declared = (latest.current.inputs ?? []).find(
|
|
(one) => `input:${qualify(flowName, one.spec?.name ?? "")}` === id,
|
|
)
|
|
const label = document.querySelector(`.react-flow__node[data-id="${id}"]`)
|
|
if (!declared || !label) return openEndpoint(id)
|
|
const { left, top, width, height } = label.getBoundingClientRect()
|
|
setInspectedInput({ declared, rect: { left, top, width, height } })
|
|
},
|
|
[flowName, openEndpoint],
|
|
)
|
|
|
|
// React Flow measures a node once and keeps the size on it. An endpoint is
|
|
// not in `canvasNodes`, so the measurement it reports back has nowhere to
|
|
// land: without carrying it over by hand the endpoint arrives unmeasured on
|
|
// the next render, and React Flow draws an unmeasured node hidden, taking
|
|
// the edges attached to it with it.
|
|
const measured = useRef(new Map<string, { width: number; height: number }>())
|
|
const trackMeasured = useCallback(
|
|
(changes: NodeChange<FlowCanvasNode>[]) => {
|
|
for (const change of changes) {
|
|
if (change.type !== "dimensions" || !change.dimensions) continue
|
|
if (isEndpointNode({ id: change.id })) {
|
|
measured.current.set(change.id, change.dimensions)
|
|
}
|
|
}
|
|
onNodesChange(changes)
|
|
},
|
|
[onNodesChange],
|
|
)
|
|
|
|
/** Selection is all an edge change can carry here: nothing else is stored. */
|
|
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
|
const selections = changes.filter((change) => change.type === "select")
|
|
if (!selections.length) return
|
|
setSelectedEdges((current) => {
|
|
const next = new Set(current)
|
|
for (const change of selections) {
|
|
if (change.selected) next.add(change.id)
|
|
else next.delete(change.id)
|
|
}
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
// 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: the key covers the wiring, which is all these depend on.
|
|
const external = useMemo(() => {
|
|
const built = deriveEndpoints(
|
|
detail.endpoints ?? [],
|
|
definitions,
|
|
flowName,
|
|
flowDoc,
|
|
)
|
|
return {
|
|
...built,
|
|
nodes: built.nodes.map((node) => {
|
|
const size = measured.current.get(node.id)
|
|
return size ? { ...node, measured: size, ...size } : node
|
|
}),
|
|
}
|
|
}, [detail.endpoints, key, flowName])
|
|
|
|
// Edges follow from the name bindings, so they are derived, never stored.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every render.
|
|
const edges = useMemo(
|
|
() => [...deriveEdges(definitions, flowName), ...external.edges],
|
|
[key, flowName, detail.endpoints],
|
|
)
|
|
|
|
// Arrived from another flow: the label standing for the node the link left
|
|
// from, or failing that any label of that flow. Its edges are lit with it,
|
|
// so the connection that was followed is the one that reads first.
|
|
const landing = useMemo(() => {
|
|
if (!selectedId?.startsWith("flow:")) return null
|
|
const ids = external.nodes.map((node) => node.id)
|
|
if (ids.includes(selectedId)) return selectedId
|
|
const origin = flowOf(selectedId.slice("flow:".length))
|
|
return ids.find((id) => id.startsWith(`flow:${origin}.`)) ?? null
|
|
}, [selectedId, external])
|
|
|
|
const isMobile = useIsMobile()
|
|
// Which way the graph runs. A phone has height to spare and no width, so it
|
|
// reads top to bottom; everything else reads left to right.
|
|
const direction: Direction = isMobile ? "TB" : "LR"
|
|
|
|
// Expanding is a desktop affordance, so a window narrowed past `md` gives the
|
|
// room back: the sheet it becomes has no second column to hold, and its body
|
|
// only scrolls while the editor is its normal size.
|
|
useEffect(() => {
|
|
if (isMobile) setEditorExpanded(false)
|
|
}, [isMobile])
|
|
|
|
/**
|
|
* Nobody places a node here — the graph lays itself out, endpoints included,
|
|
* so a producer lands upstream of what it feeds without a lane of its own.
|
|
*
|
|
* Keyed on which nodes exist and how they are wired, never on the node
|
|
* objects: React Flow writes measurements back through `onNodesChange`, so
|
|
* their identity changes constantly and the layout would run on every frame.
|
|
*/
|
|
const ids = [
|
|
...canvasNodes.map((node) => node.id),
|
|
...external.nodes.map((node) => node.id),
|
|
]
|
|
const shapeKey = `${direction}|${key}|${ids.join(",")}`
|
|
// What each node is, as far as the layout is concerned: the ports it draws,
|
|
// in the order it draws them. A function of the document — the bindings key
|
|
// above already covers every port, so this changes exactly when the layout
|
|
// has to run again, and nothing measured is ever fed back into it.
|
|
const shapes = new Map<string, NodeShape>([
|
|
...definitions.map(
|
|
(node) =>
|
|
[
|
|
node.id,
|
|
{
|
|
requires: (node.requires ?? []).map(portOf),
|
|
provides: (node.provides ?? []).map(portOf),
|
|
},
|
|
] as const,
|
|
),
|
|
...external.nodes.map((node) => {
|
|
const data = node.data as EndpointNodeData
|
|
return [
|
|
node.id,
|
|
{ requires: data.requires, provides: data.provides, endpoint: true },
|
|
] as const
|
|
}),
|
|
])
|
|
const layout = useLayout(ids, edges, direction, shapes, shapeKey)
|
|
const positions = layout?.positions ?? null
|
|
|
|
/**
|
|
* The endpoints, placed.
|
|
*
|
|
* Memoised rather than mapped at render: React Flow keeps a node's
|
|
* measurement against the object it measured, and an endpoint is not in
|
|
* `canvasNodes`, so handing over a fresh one every render would leave it
|
|
* permanently unmeasured — which React Flow draws as hidden.
|
|
*/
|
|
const externalNodes = useMemo(
|
|
() =>
|
|
external.nodes
|
|
// A node the layout has not placed yet — one just added, or the whole
|
|
// graph on the first render — is left out rather than drawn at the
|
|
// origin, which is where every unplaced node would pile up.
|
|
.filter((node) => positions?.has(node.id))
|
|
.map((node) => ({
|
|
...node,
|
|
selected: node.id === landing,
|
|
position: positions?.get(node.id) ?? node.position,
|
|
})),
|
|
[external, positions, landing],
|
|
)
|
|
|
|
// Marked after the layout has had the derived array, so selecting an edge
|
|
// never sends the graph through the layout again. The route rides along
|
|
// the same way.
|
|
const shownEdges = useMemo(
|
|
() =>
|
|
edges.map((edge) => ({
|
|
...edge,
|
|
data: {
|
|
...(edge.data as FlowEdgeData),
|
|
route: layout?.routes.get(edge.id),
|
|
},
|
|
selected:
|
|
selectedEdges.has(edge.id) ||
|
|
edge.source === landing ||
|
|
edge.target === landing,
|
|
})),
|
|
[edges, selectedEdges, landing, layout],
|
|
)
|
|
|
|
const shownNodes = useMemo(
|
|
() => [
|
|
...renderedNodes
|
|
.filter((node) => positions?.has(node.id))
|
|
.map((node) => ({
|
|
...node,
|
|
position: positions?.get(node.id) ?? node.position,
|
|
})),
|
|
...externalNodes,
|
|
],
|
|
[renderedNodes, externalNodes, positions],
|
|
)
|
|
|
|
// Editing ports adds and removes handles, and flipping direction moves them
|
|
// to the other side. 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),
|
|
...external.nodes.map((node) => node.id),
|
|
])
|
|
}, [key, direction, external, updateNodeInternals])
|
|
|
|
// An input the canvas draws is read where its label sits, as clicking it
|
|
// does, so arriving on one waits for the layout to place the label.
|
|
const arrival =
|
|
focus?.startsWith("input:") && external.nodes.some((n) => n.id === focus)
|
|
? focus
|
|
: null
|
|
const arrived = useRef<string | null>(null)
|
|
|
|
// The canvas is not remounted per node — that would throw the session away
|
|
// on every click in the brain graph — so arriving at a flow already open
|
|
// only changes the address. Seeded above for the first arrival, set here for
|
|
// the ones after it. An endpoint — an input or output found by the search —
|
|
// does what clicking its label does, which also covers one the canvas does
|
|
// not draw because a dashboard already accounts for it. Another flow's label
|
|
// is landed on instead, being where a link from that flow arrives.
|
|
useEffect(() => {
|
|
if (!focus) return
|
|
if (landsOn(focus)) {
|
|
setFlowPanelOpen(false)
|
|
setSelectedId(focus)
|
|
} else if (!arrival) {
|
|
openEndpoint(focus)
|
|
}
|
|
}, [focus, arrival, openEndpoint])
|
|
|
|
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
|
|
|
|
// One effect owns the viewport, so nothing fights over it. Selecting a node
|
|
// brings that node into the lane the panel leaves; every other change to the
|
|
// graph — new wiring, a new endpoint, a panel opening — re-fits the whole flow
|
|
// into the same lane. Which of the two runs is decided by what changed, not by
|
|
// what is true: a selection centres once, and the port edits that follow it
|
|
// re-fit around it, because a new edge's far end is what wants to be seen.
|
|
// The first fit is instant: an animated one travels from React Flow's
|
|
// default viewport to the content, which is the whole flow visibly sliding
|
|
// in from the corner every time one is opened. Later fits move from
|
|
// somewhere the user was already looking, so those stay animated.
|
|
const fitted = useRef(false)
|
|
const centred = useRef<string | null>(null)
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape or the panel changes, not on every render.
|
|
useEffect(() => {
|
|
// Recorded as centred only once the fit below has run: arriving from
|
|
// another screen selects before the layout has answered, and spending the
|
|
// selection on the early returns is what fitted the whole flow instead.
|
|
// Another flow's label is centred once it has been found on this canvas.
|
|
const target = selectedId?.startsWith("flow:") ? landing : selectedId
|
|
// A label found by the search is centred, and read once it is there.
|
|
const label = arrival && arrival !== arrived.current ? arrival : null
|
|
const focus =
|
|
label ?? (target && selectedId !== centred.current ? target : null)
|
|
// A phone's sheet covers the canvas outright, and so does the expanded
|
|
// editor: there is no viewport to aim.
|
|
if (editorExpanded) return
|
|
// Nothing is on the canvas until the layout has answered, and fitting an
|
|
// empty one would spend the single instant fit on nothing.
|
|
if (!positions) return
|
|
const frame = requestAnimationFrame(() => {
|
|
const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW
|
|
const duration = fitted.current ? 300 : 0
|
|
const fit = fitView(
|
|
focus
|
|
? { ...view, nodes: [{ id: focus }], duration }
|
|
: { ...view, duration },
|
|
)
|
|
fitted.current = true
|
|
centred.current = selectedId
|
|
if (label) {
|
|
arrived.current = label
|
|
// The frame lets the canvas draw the view it was just given.
|
|
void fit.then(() => requestAnimationFrame(() => inspectInput(label)))
|
|
}
|
|
})
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [
|
|
arrival,
|
|
inspectInput,
|
|
direction,
|
|
definitions.length,
|
|
external.nodes.length,
|
|
edges.length,
|
|
positions,
|
|
selectedId,
|
|
landing,
|
|
panelOpen,
|
|
editorExpanded,
|
|
isMobile,
|
|
fitView,
|
|
])
|
|
|
|
const runMutation = useMutation({
|
|
mutationFn: (inputs: Record<string, unknown> = {}) =>
|
|
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."),
|
|
})
|
|
|
|
/**
|
|
* Pressing Run.
|
|
*
|
|
* A batch flow is submitted as a run, and a run is identified by its
|
|
* parameters — so it asks for them rather than quietly using the defaults.
|
|
*/
|
|
const startRun = useCallback(async () => {
|
|
// Running executes what is stored, so the queued edit goes first.
|
|
await flush()
|
|
if (latest.current.mode === "batch") setRunOpen(true)
|
|
else runMutation.mutate({})
|
|
}, [flush, runMutation])
|
|
|
|
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: async (withRuns: boolean) => {
|
|
await FlowsService.deleteFlow({ name: flowName })
|
|
// After the flow, which is refused while a run is live, so a refusal
|
|
// leaves both standing.
|
|
if (withRuns)
|
|
await RunsService.deleteRuns({ flow: flowName, history: true })
|
|
},
|
|
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: async ({
|
|
nodeId,
|
|
code,
|
|
drop,
|
|
}: {
|
|
nodeId: string
|
|
code: string
|
|
drop?: boolean
|
|
}) => {
|
|
// The server reads the ports off the header and compares them with its
|
|
// draft, so the ports on the canvas have to be there first.
|
|
await flush()
|
|
return FlowsService.saveNodeSource({
|
|
name: flowName,
|
|
nodeId,
|
|
requestBody: { code },
|
|
drop,
|
|
})
|
|
},
|
|
// One at a time: the save that drops a port must not land before the
|
|
// autosave that kept it.
|
|
scope: { id: `source:${flowName}` },
|
|
onSuccess: (saved) => {
|
|
const followed = saved.node
|
|
if (followed && saved.version) {
|
|
// The header changed the ports in the draft: take them, and the
|
|
// version they moved it to, so the next save is not a conflict.
|
|
adopt(saved.version)
|
|
commit(
|
|
(latest.current.nodes ?? []).map((node) =>
|
|
node.id === followed.id
|
|
? {
|
|
...node,
|
|
requires: followed.requires,
|
|
params: followed.params,
|
|
}
|
|
: node,
|
|
),
|
|
)
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ["flows", flowName] })
|
|
},
|
|
})
|
|
|
|
const addNode = useCallback(
|
|
(type: string, sourceRef?: string) => {
|
|
const id = uniqueNodeId(definitions, sourceRef ?? type)
|
|
const node: NodeDef_Input = {
|
|
id,
|
|
type,
|
|
params: {},
|
|
requires: [],
|
|
provides: [],
|
|
// A shared node brings the code; the ports and settings are this
|
|
// flow's own.
|
|
...(sourceRef ? { source_ref: sourceRef } : {}),
|
|
}
|
|
// Unwired, so the layout puts it in a rank of its own until it is bound.
|
|
setCanvasNodes([
|
|
...canvasNodes,
|
|
{ id, type: "flow", position: { x: 0, y: 0 }, data: {} },
|
|
])
|
|
commit([...definitions, node])
|
|
setSelectedId(id)
|
|
},
|
|
[canvasNodes, commit, definitions, setCanvasNodes],
|
|
)
|
|
|
|
const updateNode = useCallback(
|
|
(next: NodeDef_Input) => {
|
|
commit(definitions.map((node) => (node.id === next.id ? next : node)))
|
|
},
|
|
[commit, definitions],
|
|
)
|
|
|
|
/**
|
|
* A port a device reports, on this flow's node for that device. One node per
|
|
* device keeps its ports together, so it is made only when there is none.
|
|
*/
|
|
const addDevicePort = useCallback(
|
|
(device: DevicePublic, port: ReportedPort) => {
|
|
const existing = deviceNodeOf(definitions, device.id)
|
|
if (existing) {
|
|
updateNode(withPort(existing, port))
|
|
setSelectedId(existing.id)
|
|
return
|
|
}
|
|
const id = uniqueNodeId(definitions, device.id)
|
|
const node = withPort(
|
|
{
|
|
id,
|
|
type: DEVICE_TYPE,
|
|
title: device.title || device.id,
|
|
params: { device: device.id, config: {} },
|
|
requires: [],
|
|
provides: [],
|
|
},
|
|
port,
|
|
)
|
|
setCanvasNodes([
|
|
...canvasNodes,
|
|
{ id, type: "flow", position: { x: 0, y: 0 }, data: {} },
|
|
])
|
|
commit([...definitions, node])
|
|
setSelectedId(id)
|
|
},
|
|
[canvasNodes, commit, definitions, setCanvasNodes, updateNode],
|
|
)
|
|
|
|
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
|
|
setSelectedId(id)
|
|
},
|
|
[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<FlowDetail>(
|
|
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<string, string> = {}
|
|
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)
|
|
|
|
// A multi-node copy carries whole Python sources, which can genuinely
|
|
// reach the origin's quota — and a throw from a key handler would take
|
|
// the app down rather than the copy.
|
|
const stored = safeStorage.set(
|
|
CLIPBOARD_KEY,
|
|
JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard),
|
|
)
|
|
if (!stored) {
|
|
notify("That is too much to copy. The browser store is full.", "error")
|
|
}
|
|
}, [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 = safeStorage.get(CLIPBOARD_KEY)
|
|
if (!stored) return
|
|
let clipboard: NodeClipboard
|
|
try {
|
|
clipboard = JSON.parse(stored)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
// A node that runs code is a superuser's to add, so anyone else pastes
|
|
// the rest without it.
|
|
const nodes = (clipboard.nodes ?? []).filter(
|
|
(node) => !lockedTypes.has(node.type ?? "python"),
|
|
)
|
|
if (nodes.length < (clipboard.nodes ?? []).length) {
|
|
notify("Only a superuser can add nodes that run code.", "error")
|
|
}
|
|
|
|
let pool = definitions
|
|
const pasted: NodeDef_Input[] = []
|
|
for (const node of nodes) {
|
|
const copy = { ...node, id: uniqueNodeId(pool, node.id) }
|
|
pool = [...pool, copy]
|
|
pasted.push(copy)
|
|
}
|
|
if (!pasted.length) return
|
|
|
|
setCanvasNodes([
|
|
...canvasNodes,
|
|
...pasted.map((node) => ({
|
|
id: node.id,
|
|
type: "flow",
|
|
position: { x: 0, y: 0 },
|
|
data: {},
|
|
})),
|
|
])
|
|
commit(pool)
|
|
setSelectedId(pasted[pasted.length - 1].id)
|
|
|
|
nodes.forEach((node, index) => {
|
|
const code = clipboard.sources?.[node.id]
|
|
if (code !== undefined) {
|
|
sourceMutation.mutate({ nodeId: pasted[index].id, code })
|
|
}
|
|
})
|
|
}, [
|
|
canvasNodes,
|
|
commit,
|
|
definitions,
|
|
lockedTypes,
|
|
setCanvasNodes,
|
|
sourceMutation.mutate,
|
|
])
|
|
|
|
useShortcuts(
|
|
{
|
|
"mod+z": () => step(true),
|
|
"mod+shift+z": () => step(false),
|
|
// Both decline the key unless the node clipboard has something to do
|
|
// with it, so the native copy and paste still work on the canvas. A
|
|
// text selection wins outright: that is what the user asked to copy.
|
|
"mod+c": () => {
|
|
const selection = document.getSelection()
|
|
if (selection && !selection.isCollapsed) return false
|
|
if (!canvasNodes.some((node) => node.selected) && !selectedId) {
|
|
return false
|
|
}
|
|
void copyNodes()
|
|
},
|
|
"mod+v": () => {
|
|
if (!safeStorage.get(CLIPBOARD_KEY)) return false
|
|
pasteNodes()
|
|
},
|
|
// ⌘P, not ⌘K: the sidebar's global search owns that everywhere, and this
|
|
// palette is the canvas's own, narrower thing.
|
|
"mod+p": () => 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+p"],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{/*
|
|
* Grows in from the centre rather than arriving mid-pan, the same
|
|
* entrance the brain view uses. React Flow reads a node's handle bounds
|
|
* out of the DOM once and never again, and a reading taken mid-scale is
|
|
* stored a few percent short for good — `LiveEdge` draws off the
|
|
* `sourceX`/`targetX` that come from those handles, so every edge would
|
|
* land short of its port forever. Remeasuring once the wrapper is back
|
|
* at `scale: 1` is what makes the entrance safe.
|
|
*/}
|
|
<motion.div
|
|
variants={scaleIn}
|
|
initial="hidden"
|
|
animate="visible"
|
|
onAnimationComplete={() =>
|
|
updateNodeInternals([
|
|
...definitions.map((node) => node.id),
|
|
...external.nodes.map((node) => node.id),
|
|
])
|
|
}
|
|
className="h-full w-full"
|
|
>
|
|
<ReactFlow
|
|
nodes={shownNodes}
|
|
edges={shownEdges}
|
|
onNodesChange={trackMeasured}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodesDelete={(deleted) =>
|
|
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
|
}
|
|
onNodeClick={(_, node) => {
|
|
// An endpoint is somewhere else's: opening its panel here would
|
|
// offer to edit a node this flow does not contain.
|
|
// An input is read where it sits; editing it is one press on.
|
|
if (node.id.startsWith("input:")) return inspectInput(node.id)
|
|
if (!isDocumentNode(node)) {
|
|
const wire = edges.find(
|
|
(edge) => edge.source === node.id || edge.target === node.id,
|
|
)
|
|
openEndpoint(
|
|
node.id,
|
|
wire && (wire.source === node.id ? wire.target : wire.source),
|
|
)
|
|
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)
|
|
setInspectedInput(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 }}
|
|
// React Flow's own defaults, for whatever flow.css does not route
|
|
// through the tokens, follow the theme too.
|
|
colorMode={resolvedTheme}
|
|
fitView
|
|
fitViewOptions={FIT_VIEW}
|
|
// Low enough that the fit can always show the whole graph. A phone is
|
|
// 390px wide and a rank of several nodes is thousands, so a floor of
|
|
// 0.25 left the fit silently short and the flow running off screen.
|
|
minZoom={0.1}
|
|
maxZoom={2}
|
|
// The graph places itself. Nothing here is arranged by hand, which is
|
|
// what keeps a flow small enough to read at a glance.
|
|
nodesDraggable={false}
|
|
connectionRadius={30}
|
|
connectOnClick
|
|
autoPanOnConnect
|
|
edgesReconnectable={false}
|
|
deleteKeyCode={["Backspace", "Delete"]}
|
|
className="h-full w-full"
|
|
>
|
|
<CanvasBackground />
|
|
</ReactFlow>
|
|
</motion.div>
|
|
|
|
{/*
|
|
* 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.
|
|
*/}
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute inset-0 transition-[right] duration-200",
|
|
panelOpen && !editorExpanded && "md:right-[27rem]",
|
|
)}
|
|
>
|
|
<CanvasTitle
|
|
className={cn(
|
|
"transition-transform duration-200",
|
|
editorExpanded && "-translate-y-[calc(100%+1rem)]",
|
|
)}
|
|
>
|
|
<span className="truncate px-3 py-1.5 text-sm font-medium">
|
|
{flowDoc.title || flowName}
|
|
</span>
|
|
{/* Declared in code somewhere else, at a commit that names what
|
|
actually ran. A dirty tree says so, because then it does not. */}
|
|
{flowDoc.origin ? (
|
|
<span
|
|
className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground"
|
|
title={flowDoc.origin.repo || "a repository"}
|
|
data-testid="flow-origin"
|
|
>
|
|
{flowDoc.origin.commit
|
|
? flowDoc.origin.commit.slice(0, 7)
|
|
: "no commit"}
|
|
{flowDoc.origin.dirty ? "*" : ""}
|
|
</span>
|
|
) : null}
|
|
</CanvasTitle>
|
|
|
|
<FlowDock
|
|
className={cn(
|
|
"transition-transform duration-200",
|
|
editorExpanded && "translate-y-[calc(100%+1rem)]",
|
|
)}
|
|
flow={flowName}
|
|
issues={issues}
|
|
running={runMutation.isPending}
|
|
enabled={detail.enabled ?? true}
|
|
paused={paused}
|
|
saving={saving.isPending}
|
|
hasDraft={detail.has_draft ?? false}
|
|
publishing={publish.isPending || saving.isPending}
|
|
onEditFlow={() => {
|
|
setSelectedId(null)
|
|
setFlowPanelOpen(true)
|
|
}}
|
|
onPublish={() => void publishFlow()}
|
|
onDiscard={() => setDiscardOpen(true)}
|
|
logs={{
|
|
open: logsOpen,
|
|
node: logsNode,
|
|
onOpenChange: (open) => {
|
|
setLogsOpen(open)
|
|
if (!open) setLogsNode(null)
|
|
},
|
|
onClearNode: () => setLogsNode(null),
|
|
}}
|
|
onAddNode={() => setPaletteOpen(true)}
|
|
onRun={startRun}
|
|
onTogglePause={() => pauseMutation.mutate(!paused)}
|
|
onStep={() => stepMutation.mutate()}
|
|
stepping={stepMutation.isPending}
|
|
onFocusNode={focusNode}
|
|
/>
|
|
</div>
|
|
|
|
{definitions.length === 0 ? (
|
|
<div
|
|
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
data-testid="flow-empty"
|
|
>
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<span className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
<Workflow className="size-6" />
|
|
</span>
|
|
<p className="text-lg font-medium">This flow is empty</p>
|
|
<p className="max-w-xs text-sm text-muted-foreground">
|
|
Add a node to get started. Press ⌘P, or use the plus in the bar
|
|
below.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<FlowPanel
|
|
open={flowPanelOpen && !selected}
|
|
definition={flowDoc}
|
|
nodeCount={definitions.length}
|
|
// Confirmed, so it is a finished edit rather than a run of keystrokes.
|
|
onChange={(next) => commitDoc({ ...next, nodes: definitions }, true)}
|
|
onDelete={(withRuns) => deleteMutation.mutate(withRuns)}
|
|
enabled={detail.enabled ?? true}
|
|
toggling={enableMutation.isPending}
|
|
onToggleEnabled={(next) => enableMutation.mutate(next)}
|
|
hasDraft={detail.has_draft ?? false}
|
|
onClose={() => setFlowPanelOpen(false)}
|
|
/>
|
|
|
|
<NodePanel
|
|
node={selected}
|
|
flow={flowName}
|
|
nodeTypes={nodeTypeInfo ?? []}
|
|
origin={flowDoc.origin}
|
|
suggestions={suggestions}
|
|
readers={readers}
|
|
expanded={editorExpanded}
|
|
onToggleExpand={() => setEditorExpanded((wide) => !wide)}
|
|
onChange={updateNode}
|
|
onRenameMessage={proposeRename}
|
|
onSaveSource={(code, drop) => {
|
|
if (selected)
|
|
sourceMutation.mutate({ nodeId: selected.id, code, drop })
|
|
}}
|
|
// 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])}
|
|
/>
|
|
|
|
<EdgeInspector
|
|
edge={inspected}
|
|
flow={flowName}
|
|
onClose={() => setInspected(null)}
|
|
onUnbind={unbind}
|
|
/>
|
|
|
|
<InputInspector
|
|
input={inspectedInput}
|
|
flow={flowName}
|
|
onClose={() => setInspectedInput(null)}
|
|
onEdit={() => {
|
|
setInspectedInput(null)
|
|
setSelectedId(null)
|
|
setFlowPanelOpen(true)
|
|
}}
|
|
/>
|
|
|
|
<CommandPalette
|
|
open={paletteOpen}
|
|
onOpenChange={setPaletteOpen}
|
|
nodeTypes={(nodeTypeInfo ?? []).filter(
|
|
(info) => !lockedTypes.has(info.type),
|
|
)}
|
|
flows={flows.data}
|
|
onAddNode={addNode}
|
|
onAddSharedNode={
|
|
mayEditCode ? (libName) => addNode("python", libName) : undefined
|
|
}
|
|
onAddDevicePort={addDevicePort}
|
|
onRun={startRun}
|
|
/>
|
|
|
|
<RunDialog
|
|
open={runOpen}
|
|
definition={flowDoc}
|
|
pending={runMutation.isPending}
|
|
onOpenChange={setRunOpen}
|
|
onRun={(params) => {
|
|
setRunOpen(false)
|
|
runMutation.mutate(params)
|
|
}}
|
|
/>
|
|
|
|
<Dialog
|
|
open={Boolean(rebind)}
|
|
onOpenChange={(open) => !open && setRebind(null)}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>How should {rebind?.nodeLabel} read this?</DialogTitle>
|
|
<DialogDescription>
|
|
Its "{rebind?.port}" input already reads{" "}
|
|
<span className="font-mono">{rebind?.from}</span>. It can take{" "}
|
|
<span className="font-mono">{rebind?.to}</span> as well, or
|
|
instead.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="sm:justify-between">
|
|
<Button variant="ghost" onClick={() => setRebind(null)}>
|
|
Cancel
|
|
</Button>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (rebind) {
|
|
applyBinding(rebind.nodeId, rebind.port, rebind.to)
|
|
}
|
|
setRebind(null)
|
|
}}
|
|
>
|
|
Replace
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
if (rebind) {
|
|
addInputPort(rebind.nodeId, rebind.to, rebind.dtype)
|
|
}
|
|
setRebind(null)
|
|
}}
|
|
>
|
|
Add as another input
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(renamed)}
|
|
onOpenChange={(open) => !open && setRenamed(null)}
|
|
>
|
|
<DialogContent data-testid="rename-message">
|
|
<DialogHeader>
|
|
<DialogTitle>Rename this message everywhere?</DialogTitle>
|
|
<DialogDescription>
|
|
{renamed?.count === 1
|
|
? "One other node is"
|
|
: `${renamed?.count} other nodes are`}{" "}
|
|
still bound to <span className="font-mono">{renamed?.from}</span>.
|
|
They keep the old name unless they follow it to{" "}
|
|
<span className="font-mono">{renamed?.to}</span>.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="sm:justify-between">
|
|
<Button variant="ghost" onClick={() => setRenamed(null)}>
|
|
Leave them
|
|
</Button>
|
|
<Button onClick={() => renamed && applyRename(renamed)}>
|
|
Rename everywhere
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<DiscardDraftDialog
|
|
open={discardOpen}
|
|
onOpenChange={setDiscardOpen}
|
|
pending={discard.isPending}
|
|
testId="confirm-discard-draft"
|
|
onConfirm={() =>
|
|
discard.mutate(undefined, {
|
|
// The published document replaces what is on the canvas, and
|
|
// the version counter goes back with it.
|
|
onSuccess: () => {
|
|
setFlowPanelOpen(false)
|
|
onReload()
|
|
},
|
|
})
|
|
}
|
|
>
|
|
The canvas goes back to the version the engine is running. What you
|
|
edited since is dropped, though the flow store's git history keeps it.
|
|
</DiscardDraftDialog>
|
|
|
|
{/*
|
|
* Not dismissable: until one version wins, every further save fails, so
|
|
* there is nothing useful to go back to.
|
|
*/}
|
|
<Dialog open={conflict}>
|
|
<DialogContent
|
|
data-testid="save-conflict"
|
|
showCloseButton={false}
|
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
|
onPointerDownOutside={(event) => event.preventDefault()}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>Someone else changed this flow</DialogTitle>
|
|
<DialogDescription>
|
|
Another editor saved <span className="font-mono">{flowName}</span>{" "}
|
|
while you were working on it. Load their version, or keep yours
|
|
and write over theirs.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="sm:justify-between">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
void resolveConflict("theirs")
|
|
onReload()
|
|
}}
|
|
>
|
|
Load theirs
|
|
</Button>
|
|
<Button onClick={() => void resolveConflict("mine")}>
|
|
Keep mine
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Seed xyflow's own node state once. It tracks which nodes exist and which are
|
|
* selected; the positions on it are placeholders the layout replaces at render.
|
|
*/
|
|
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
|
|
return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
|
|
}
|
|
|
|
export function FlowEditor({
|
|
flowName,
|
|
focus,
|
|
}: {
|
|
flowName: string
|
|
/** A node or endpoint to arrive on — see `FlowEditorInner`. */
|
|
focus?: 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 (
|
|
<ReactFlowProvider>
|
|
{/*
|
|
* 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.
|
|
* Deliberately not per focused node — that is a selection, not another
|
|
* document, and remounting the canvas on every click in the brain graph
|
|
* would throw an unsaved edit away with it.
|
|
*/}
|
|
<FlowEditorInner
|
|
key={`${flowName}:${epoch}`}
|
|
flowName={flowName}
|
|
focus={focus}
|
|
onReload={reload}
|
|
/>
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|