import { useReactFlow } from "@xyflow/react" import { AlertCircle, Check, Maximize2, Pause, Pencil, Play, PlayCircle, Plus, StepForward, WifiOff, X, } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import { useEffect, useState } from "react" import type { ValidationIssue } from "@/client" import { Button } from "@/components/ui/button" import { FluksioLoader } from "@/components/ui/fluksio-loader" import { Separator } from "@/components/ui/separator" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { slideUp, transitions } from "@/lib/motion" import { cn } from "@/lib/utils" import { type LogsFilter, LogsPanel, LogsTrigger, PANEL } from "./LogsPanel" import { useLiveConnection } from "./liveStore" /** * What "fit" means on this canvas: the view a flow opens with, and the one the * fit button returns to. The generous padding keeps the graph clear of the * chrome floating over it, and the cap stops a two-node flow from being blown * up past legibility. */ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 } /** * The same fit with the settings panel's lane held clear, so an opening panel * never lands on the node you are looking at. 432px is the lane the chrome is * pushed out of by `md:right-[27rem]`; xyflow's padding parser takes px and %, * so it cannot be spelled in rem. 10% per side is what `padding: 0.25` above * resolves to, so the two fits are equally generous. */ export const FIT_VIEW_PANEL = { maxZoom: FIT_VIEW.maxZoom, // `as const` because xyflow types each side as `${number}px | ${number}%`, // which a widened `string` does not satisfy. padding: { top: "10%", bottom: "10%", left: "10%", right: "432px" } as const, } /** * The action bar, floating bottom-centre. Run is the one brand-secondary * affordance on this view; everything else stays quiet. * * Everything the flow bar used to carry is here too — whether the work is * saved, the flow's own settings, and putting it live or throwing it away — * so the top of the canvas is left to say which flow this is. * * It wraps rather than overflows, and drops the fit control on a phone; see * DESIGN-GUIDELINES.md → Responsive. */ export function FlowDock({ flow, issues, running, enabled, paused, saving, hasDraft, publishing, logs, onAddNode, onRun, onTogglePause, onStep, stepping, onFocusNode, onEditFlow, onPublish, onDiscard, className, }: { flow: string issues: ValidationIssue[] running: boolean enabled: boolean paused: boolean saving: boolean hasDraft: boolean publishing: boolean /** Owned by the editor, because a failing node can open it too. */ logs: LogsFilter onAddNode: () => void onRun: () => void onTogglePause: () => void onStep: () => void stepping: boolean onFocusNode: (nodeId: string) => void onEditFlow: () => void onPublish: () => void onDiscard: () => void className?: string }) { const { fitView } = useReactFlow() const connected = useLiveConnection() const [issuesOpen, setIssuesOpen] = useState(false) // The engine marks the issues it does not count against a flow. They are // still worth saying, so they stay in the list — but in the muted tone, and // without turning the summary red, since nothing here is actually broken. const faults = issues.filter((issue) => !issue.advisory) return ( // The panels and the bar are siblings in one column, so neither nests its // half-pixel centring translate or its backdrop filter inside the other — // which is what Firefox was drawing the console's text through. It also // gives the two panels a row to share, instead of one landing on the other.
setIssuesOpen(false)} onFocusNode={onFocusNode} />
Add a node (⌘P) {/* A phone pinches to zoom and the graph fits itself, so this would only be taking room the rest of the bar needs. */} Fit to screen {issues.length > 0 ? ( <> ) : null} {!enabled ? "This flow is stopped" : paused ? "Let the flow carry on" : "Hold the nodes; values still arrive"} {paused && enabled ? ( Let one held-back item through ) : null} {enabled ? "Run every node once" : "Start the flow to run it"} Flow settings {/* Throwing the edit away sits next to putting it live, and only exists while there is something to throw away. */} {hasDraft ? ( Discard the unpublished changes ) : null} {/* * Saved state and publish are one control: the glyph never moves, it * simply stops being something you can press once there is nothing left * to put live. A button that appears and disappears moved everything * beside it just as the work was finished. */} {!connected ? "Reconnecting to the engine" : publishing ? "Publishing" : saving ? "Saving" : hasDraft ? "Saved — publish to put it live" : "All changes saved"}
) } /** * What stops this flow running, or is merely worth knowing — the same chrome as * the logs, so a canvas with both open shows one pair rather than a panel and a * popover disagreeing about what a panel looks like. * * Not a popover for the same reason the logs are not: clicking a node is what * you do while reading this, since each line points at one. */ function IssuesPanel({ open, issues, onClose, onFocusNode, }: { open: boolean issues: ValidationIssue[] onClose: () => void onFocusNode: (nodeId: string) => void }) { useEffect(() => { if (!open) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose() } window.addEventListener("keydown", onKeyDown) return () => window.removeEventListener("keydown", onKeyDown) }, [open, onClose]) return ( {open && issues.length > 0 ? (

Needs attention

) : null}
) }