Add the flow editor: canvas, node panel and live values
The browser half of M3. Flows open on a full-bleed canvas with their chrome floating over it: flow tabs top, dock bottom, node settings in a panel on the right that leaves the graph visible and running behind it. - Connections are derived, not stored. A node declares the messages it reads and publishes; every matching pair draws an edge, so two producers of one message converge on their consumer. Dragging output to input is shorthand for pointing that input at the producer's message, and asks before it replaces an existing one. - Values land on the edges as they flow, over a websocket that feeds a store outside React, so a value arriving re-renders its own chip and nothing else. Clicking an edge shows the last payload and when it arrived. - Node source is edited in Monaco, loaded only when a panel opens and themed from the design tokens. - Edits autosave; identical documents are skipped server-side, so a quiet canvas writes nothing. - Validation from the API shows on the node it belongs to and is summarised in the dock, where each entry pans to its node. - Works on a phone: touch-connect, 44px dock targets, and the node panel becomes a full-screen sheet. Two new tokens (--status-success, --font-mono) are mirrored in the website repo and recorded in DESIGN-GUIDELINES.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
co-authored by
Claude Fable 5
parent
06a4506767
commit
8c82549cf6
@@ -0,0 +1,113 @@
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import type { FlowSummary, NodeTypeInfo } from "@/client"
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
|
||||
/**
|
||||
* ⌘K: add a node, jump to another flow, or run the current one, without
|
||||
* reaching for the dock.
|
||||
*/
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
nodeTypes,
|
||||
flows,
|
||||
onAddNode,
|
||||
onRun,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
nodeTypes: NodeTypeInfo[]
|
||||
flows: FlowSummary[]
|
||||
onAddNode: (type: string) => void
|
||||
onRun: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
onOpenChange(!open)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// Picking an item re-renders the canvas, which can interrupt the dialog's
|
||||
// exit animation and leave its overlay swallowing clicks. Unmounting the
|
||||
// dialog outright is deterministic; the palette does not need to fade out.
|
||||
if (!open) return null
|
||||
|
||||
const close = (action: () => void) => {
|
||||
onOpenChange(false)
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Commands"
|
||||
description="Add a node, switch flow, or run"
|
||||
>
|
||||
<CommandInput placeholder="Add a node, switch flow, run…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nothing matches that.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Add a node">
|
||||
{nodeTypes.map((nodeType) => (
|
||||
<CommandItem
|
||||
key={nodeType.type}
|
||||
value={`${nodeType.title} ${nodeType.description}`}
|
||||
onSelect={() => close(() => onAddNode(nodeType.type))}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span>{nodeType.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nodeType.description}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{flows.length > 0 ? (
|
||||
<CommandGroup heading="Flows">
|
||||
{flows.map((flow) => (
|
||||
<CommandItem
|
||||
key={flow.name}
|
||||
value={`flow ${flow.name} ${flow.title}`}
|
||||
onSelect={() =>
|
||||
close(() =>
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName: flow.name },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{flow.title || flow.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem value="run flow" onSelect={() => close(onRun)}>
|
||||
Run this flow
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { displayName } from "./deriveEdges"
|
||||
import { useLiveValue } from "./liveStore"
|
||||
|
||||
function relativeTime(ts: number | null | undefined): string {
|
||||
if (!ts) return "not seen yet"
|
||||
const seconds = Math.max(0, Math.round(Date.now() / 1000 - ts))
|
||||
if (seconds < 5) return "just now"
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`
|
||||
return `${Math.round(seconds / 3600)}h ago`
|
||||
}
|
||||
|
||||
export type InspectedEdge = {
|
||||
message: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* What last travelled along an edge. The value is whatever the producing node
|
||||
* published, shown as it was serialised.
|
||||
*/
|
||||
export function EdgeInspector({
|
||||
edge,
|
||||
flow,
|
||||
onClose,
|
||||
onUnbind,
|
||||
}: {
|
||||
edge: InspectedEdge | null
|
||||
flow: string
|
||||
onClose: () => void
|
||||
onUnbind: (message: string) => void
|
||||
}) {
|
||||
const live = useLiveValue(edge?.message)
|
||||
if (!edge) return null
|
||||
|
||||
return (
|
||||
<Popover open onOpenChange={(open) => !open && onClose()}>
|
||||
<PopoverAnchor
|
||||
style={{ position: "fixed", left: edge.x, top: edge.y }}
|
||||
className="size-0"
|
||||
/>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
className="w-72 p-3"
|
||||
data-testid="edge-inspector"
|
||||
>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{displayName(flow, edge.message)}
|
||||
</p>
|
||||
|
||||
{live === undefined ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Nothing has come through yet. Run the flow to see a value here.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="mt-2 max-h-48">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
|
||||
{JSON.stringify(live.value, null, 2)}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{relativeTime(live.ts)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 h-7 w-full justify-start text-xs text-muted-foreground"
|
||||
onClick={() => onUnbind(edge.message)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useReactFlow } from "@xyflow/react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Play,
|
||||
Plus,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import type { ValidationIssue } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { slideUp, transitions } from "@/lib/motion"
|
||||
|
||||
/**
|
||||
* The action bar, floating bottom-centre. Run is the one brand-secondary
|
||||
* affordance on this view; everything else stays quiet.
|
||||
*/
|
||||
export function FlowDock({
|
||||
issues,
|
||||
running,
|
||||
onAddNode,
|
||||
onRun,
|
||||
onFocusNode,
|
||||
}: {
|
||||
issues: ValidationIssue[]
|
||||
running: boolean
|
||||
onAddNode: () => void
|
||||
onRun: () => void
|
||||
onFocusNode: (nodeId: string) => void
|
||||
}) {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={transitions.emphasized}
|
||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={onAddNode}
|
||||
aria-label="Add node"
|
||||
data-testid="add-node"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a node (⌘K)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => fitView({ duration: 300 })}
|
||||
aria-label="Fit the flow to the screen"
|
||||
>
|
||||
<Maximize2 />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Fit to screen</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
|
||||
{issues.length > 0 ? (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-11 gap-1.5 text-destructive md:h-8"
|
||||
data-testid="validation-summary"
|
||||
>
|
||||
<AlertCircle className="size-4" />
|
||||
{issues.length}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-80 p-2">
|
||||
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||
Needs attention
|
||||
</p>
|
||||
<ul className="mt-1 grid gap-0.5">
|
||||
{issues.map((issue) => (
|
||||
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent"
|
||||
onClick={() => issue.node && onFocusNode(issue.node)}
|
||||
disabled={!issue.node}
|
||||
>
|
||||
{issue.message}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm"
|
||||
className="h-11 gap-1.5 md:h-8"
|
||||
onClick={onRun}
|
||||
disabled={running}
|
||||
data-testid="run-flow"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
type Connection,
|
||||
type Node as FlowCanvasNode,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
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, FlowsService, 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 { CommandPalette } from "./CommandPalette"
|
||||
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||
import { FlowDock } from "./FlowDock"
|
||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||
import { FlowTabs } from "./FlowTabs"
|
||||
import { LiveEdge } from "./LiveEdge"
|
||||
import { NodePanel } from "./NodePanel"
|
||||
import "./flow.css"
|
||||
import { liveStore } from "./liveStore"
|
||||
import {
|
||||
flowQueryOptions,
|
||||
flowsQueryOptions,
|
||||
nodeTypesQueryOptions,
|
||||
useAutosave,
|
||||
} from "./queries"
|
||||
import { useFlowSocket } from "./useFlowSocket"
|
||||
|
||||
const nodeTypes = { flow: FlowNode }
|
||||
const edgeTypes = { live: LiveEdge }
|
||||
|
||||
type Rebind = {
|
||||
nodeId: string
|
||||
port: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
/** 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 }: { flowName: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { showErrorToast } = 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, mutation: saving } = useAutosave(flowName)
|
||||
|
||||
const [definitions, setDefinitions] = useState<NodeDef_Input[]>(
|
||||
() => detail.definition.nodes ?? [],
|
||||
)
|
||||
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
|
||||
detail.definition.nodes ?? [],
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
|
||||
const [rebind, setRebind] = useState<Rebind | null>(null)
|
||||
|
||||
const issues = detail.issues ?? []
|
||||
|
||||
// Keep the latest document in a ref so autosave never captures a stale copy.
|
||||
const latest = useRef<FlowDef_Input>(detail.definition)
|
||||
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
|
||||
})
|
||||
setDefinitions(placed)
|
||||
const next: FlowDef_Input = { ...detail.definition, nodes: placed }
|
||||
latest.current = next
|
||||
save(next)
|
||||
},
|
||||
[canvasNodes, detail.definition, save],
|
||||
)
|
||||
|
||||
const typeLabels = useMemo(
|
||||
() => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])),
|
||||
[nodeTypeInfo],
|
||||
)
|
||||
|
||||
const issuesByNode = useMemo(() => {
|
||||
const map = new Map<string, string[]>()
|
||||
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])
|
||||
|
||||
// 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 ?? "",
|
||||
issues: nodeIssues.length,
|
||||
issueText: nodeIssues.join("\n"),
|
||||
} satisfies FlowNodeData,
|
||||
}
|
||||
}),
|
||||
[canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels],
|
||||
)
|
||||
|
||||
// Edges follow from the name bindings, so they are derived, never stored.
|
||||
const key = bindingsKey(definitions)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
||||
const edges = useMemo(
|
||||
() => deriveEdges(definitions, flowName),
|
||||
[key, flowName],
|
||||
)
|
||||
|
||||
// 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 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) => {
|
||||
const id = uniqueNodeId(definitions, 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: [],
|
||||
}
|
||||
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
|
||||
|
||||
if (inSpec.name && inSpec.name !== outSpec.name) {
|
||||
setRebind({
|
||||
nodeId: consumer.id,
|
||||
port: portOf(inSpec),
|
||||
from: inSpec.name,
|
||||
to: outSpec.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
applyBinding(consumer.id, portOf(inSpec), outSpec.name)
|
||||
},
|
||||
[definitions, applyBinding],
|
||||
)
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={renderedNodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_event, _node, dragged) =>
|
||||
commit(definitions, mergeDragged(canvasNodes, dragged))
|
||||
}
|
||||
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
|
||||
onNodeClick={(_event, node) => setSelectedId(node.id)}
|
||||
onPaneClick={() => {
|
||||
setSelectedId(null)
|
||||
setInspected(null)
|
||||
}}
|
||||
onEdgeClick={(event, edge) => {
|
||||
setInspected({
|
||||
message: (edge.data as { message: string }).message,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={{ maxZoom: 1, padding: 0.25 }}
|
||||
minZoom={0.25}
|
||||
maxZoom={2}
|
||||
nodeDragThreshold={5}
|
||||
connectionRadius={30}
|
||||
connectOnClick
|
||||
autoPanOnConnect
|
||||
edgesReconnectable={false}
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
|
||||
</ReactFlow>
|
||||
|
||||
<FlowTabs
|
||||
flows={flows.data}
|
||||
active={flowName}
|
||||
saving={saving.isPending}
|
||||
/>
|
||||
|
||||
<FlowDock
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
onAddNode={() => setPaletteOpen(true)}
|
||||
onRun={() => {
|
||||
flush()
|
||||
runMutation.mutate()
|
||||
}}
|
||||
onFocusNode={focusNode}
|
||||
/>
|
||||
|
||||
{definitions.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<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 ⌘K, or use the plus in the bar
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NodePanel
|
||||
node={selected}
|
||||
flow={flowName}
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
onChange={updateNode}
|
||||
onSaveSource={(code) => {
|
||||
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
|
||||
}}
|
||||
onClose={() => {
|
||||
flush()
|
||||
setSelectedId(null)
|
||||
}}
|
||||
onDelete={() => selected && deleteNodes([selected.id])}
|
||||
/>
|
||||
|
||||
<EdgeInspector
|
||||
edge={inspected}
|
||||
flow={flowName}
|
||||
onClose={() => setInspected(null)}
|
||||
onUnbind={unbind}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
flows={flows.data}
|
||||
onAddNode={addNode}
|
||||
onRun={() => runMutation.mutate()}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(rebind)}
|
||||
onOpenChange={(open) => !open && setRebind(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change what this input reads?</DialogTitle>
|
||||
<DialogDescription>
|
||||
"{rebind?.port}" currently reads {rebind?.from}. Point it at{" "}
|
||||
{rebind?.to} instead?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRebind(null)}>
|
||||
Keep {rebind?.from}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (rebind) applyBinding(rebind.nodeId, rebind.port, rebind.to)
|
||||
setRebind(null)
|
||||
}}
|
||||
>
|
||||
Read {rebind?.to}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<FlowCanvasNode>(
|
||||
definitions.map((node) => ({
|
||||
id: node.id,
|
||||
type: "flow",
|
||||
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
|
||||
data: {},
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowEditor({ flowName }: { flowName: string }) {
|
||||
const navigate = useNavigate()
|
||||
const onAuthFailure = useCallback(() => {
|
||||
navigate({ to: "/login" })
|
||||
}, [navigate])
|
||||
|
||||
useFlowSocket(onAuthFailure)
|
||||
|
||||
useEffect(() => {
|
||||
return () => liveStore.reset()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
{/* Remounting per flow keeps canvas state from leaking between them. */}
|
||||
<FlowEditorInner key={flowName} flowName={flowName} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Braces,
|
||||
Clock,
|
||||
Code2,
|
||||
Database,
|
||||
Globe,
|
||||
Radio,
|
||||
} from "lucide-react"
|
||||
import { memo } from "react"
|
||||
|
||||
import type { MessageSpec, NodeDef_Input } from "@/client"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { portOf } from "./deriveEdges"
|
||||
import { useNodeStatus } from "./liveStore"
|
||||
|
||||
const NODE_ICONS = {
|
||||
python: Code2,
|
||||
mqtt: Radio,
|
||||
http: Globe,
|
||||
influxdb: Database,
|
||||
delay: Clock,
|
||||
mlp: Braces,
|
||||
} as const
|
||||
|
||||
const STATUS_STYLES = {
|
||||
running: { dot: "bg-primary animate-pulse", label: "Running" },
|
||||
success: { dot: "bg-status-success", label: "Last run succeeded" },
|
||||
error: { dot: "bg-destructive", label: "Failed" },
|
||||
} as const
|
||||
|
||||
export type FlowNodeData = {
|
||||
definition: NodeDef_Input
|
||||
flow: string
|
||||
typeLabel: string
|
||||
issues: number
|
||||
issueText: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Vertically distribute handles so several ports stay reachable. */
|
||||
function handleOffset(index: number, total: number): string {
|
||||
if (total <= 1) return "50%"
|
||||
const span = 60
|
||||
return `${50 - span / 2 + (span / (total - 1)) * index}%`
|
||||
}
|
||||
|
||||
function PortHandles({
|
||||
specs,
|
||||
type,
|
||||
position,
|
||||
}: {
|
||||
specs: MessageSpec[]
|
||||
type: "source" | "target"
|
||||
position: Position
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{specs.map((spec, index) => {
|
||||
const port = portOf(spec)
|
||||
return (
|
||||
<Handle
|
||||
key={`${type}-${port}`}
|
||||
id={port}
|
||||
type={type}
|
||||
position={position}
|
||||
className={cn(
|
||||
"!bg-card !border-muted-foreground/60",
|
||||
!spec.name && "unbound",
|
||||
)}
|
||||
style={{ top: handleOffset(index, specs.length) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowNodeComponent({ data, selected }: NodeProps) {
|
||||
const { definition, flow, typeLabel, issues, issueText } =
|
||||
data as FlowNodeData
|
||||
const live = useNodeStatus(`${flow}.${definition.id}`)
|
||||
const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2
|
||||
|
||||
const status = live?.status === "active" ? undefined : live?.status
|
||||
const style = status
|
||||
? STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
||||
selected && "border-primary shadow-e2",
|
||||
)}
|
||||
>
|
||||
<PortHandles
|
||||
specs={definition.requires ?? []}
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{definition.title || definition.id}
|
||||
</span>
|
||||
<span className="block truncate text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</span>
|
||||
{style ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
className={cn("size-2 shrink-0 rounded-full", style.dot)}
|
||||
aria-label={style.label}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{live?.error ?? style.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{issues > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-primary-foreground">
|
||||
<AlertCircle className="size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{issueText}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<PortHandles
|
||||
specs={definition.provides ?? []}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FlowNode = memo(FlowNodeComponent)
|
||||
@@ -0,0 +1,194 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { Check, Loader2, Plus, WifiOff } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type FlowSummary, FlowsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { slideUp, transitions } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useLiveConnection } from "./liveStore"
|
||||
import { flowKeys } from "./queries"
|
||||
|
||||
const nameSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Give the flow a name")
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_]*$/,
|
||||
"Lowercase letters, digits and underscores, starting with a letter",
|
||||
),
|
||||
})
|
||||
|
||||
type NameForm = z.infer<typeof nameSchema>
|
||||
|
||||
function NewFlowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const form = useForm<NameForm>({
|
||||
resolver: zodResolver(nameSchema),
|
||||
defaultValues: { name: "" },
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: NameForm) =>
|
||||
FlowsService.saveFlow({
|
||||
name: values.name,
|
||||
requestBody: { name: values.name, nodes: [], inputs: [] },
|
||||
}),
|
||||
onSuccess: (_data, values) => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
onOpenChange(false)
|
||||
form.reset()
|
||||
navigate({ to: "/flows/$flowName", params: { flowName: values.name } })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New flow</DialogTitle>
|
||||
<DialogDescription>
|
||||
Flows are small on purpose. Name this one after what it does.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2 py-4">
|
||||
<Label htmlFor="flow-name">Name</Label>
|
||||
<Input
|
||||
id="flow-name"
|
||||
data-testid="flow-name-input"
|
||||
placeholder="heating"
|
||||
autoComplete="off"
|
||||
{...form.register("name")}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Creating…" : "Create flow"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow switcher, floating top-centre over the canvas. Each flow is a chip:
|
||||
* transparent at rest, filled when it is the one you are looking at.
|
||||
*/
|
||||
export function FlowTabs({
|
||||
flows,
|
||||
active,
|
||||
saving,
|
||||
}: {
|
||||
flows: FlowSummary[]
|
||||
active: string
|
||||
saving: boolean
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const connected = useLiveConnection()
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={transitions.emphasized}
|
||||
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
|
||||
>
|
||||
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground" />
|
||||
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{flows.map((flow) => (
|
||||
<Link
|
||||
key={flow.name}
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: flow.name }}
|
||||
className={cn(
|
||||
"shrink-0 snap-start rounded-full px-3 py-1.5 text-sm transition-colors",
|
||||
flow.name === active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{flow.title || flow.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
aria-label="New flow"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New flow</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{!connected ? (
|
||||
<WifiOff className="size-3.5" />
|
||||
) : saving ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!connected
|
||||
? "Reconnecting to the engine"
|
||||
: saving
|
||||
? "Saving"
|
||||
: "All changes saved"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
|
||||
<NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
useStore,
|
||||
} from "@xyflow/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { FlowEdgeData } from "./deriveEdges"
|
||||
import { useLiveValue } from "./liveStore"
|
||||
|
||||
/** Below this zoom the value chips would be unreadable, so they step aside. */
|
||||
const CHIP_MIN_ZOOM = 0.5
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
if (typeof value === "string") return value
|
||||
return JSON.stringify(value) ?? ""
|
||||
}
|
||||
|
||||
function LiveEdgeComponent({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
selected,
|
||||
}: EdgeProps) {
|
||||
const { message } = (data ?? {}) as FlowEdgeData
|
||||
const live = useLiveValue(message)
|
||||
const zoom = useStore((state) => state.transform[2])
|
||||
const [pulsing, setPulsing] = useState(false)
|
||||
const lastTs = useRef<number | null>(null)
|
||||
|
||||
const [path, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
// Restart the stroke animation whenever a newer message lands.
|
||||
useEffect(() => {
|
||||
if (!live?.ts || live.ts === lastTs.current) return
|
||||
lastTs.current = live.ts
|
||||
setPulsing(true)
|
||||
const timer = setTimeout(() => setPulsing(false), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [live?.ts])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
className={cn(pulsing && "edge-live")}
|
||||
style={selected ? { stroke: "var(--primary)" } : undefined}
|
||||
/>
|
||||
{live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
}}
|
||||
className="pointer-events-none absolute max-w-[140px] truncate rounded-full border border-border bg-card/80 px-2 py-0.5 font-mono text-xs text-muted-foreground backdrop-blur-md"
|
||||
>
|
||||
{formatValue(live.value)}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const LiveEdge = memo(LiveEdgeComponent)
|
||||
@@ -0,0 +1,54 @@
|
||||
import Editor from "@monaco-editor/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { monacoFontFamily, setupMonaco } from "./monacoSetup"
|
||||
|
||||
/**
|
||||
* The embedded Python editor. Highlighting and bracket handling only: what the
|
||||
* code actually does is checked by the engine, which reports load and run
|
||||
* errors back onto the node.
|
||||
*/
|
||||
export default function NodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setupMonaco()
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
const fontFamily = useMemo(() => (ready ? monacoFontFamily() : ""), [ready])
|
||||
|
||||
if (!ready) return <Skeleton className="h-full w-full rounded-md" />
|
||||
|
||||
return (
|
||||
<Editor
|
||||
language="python"
|
||||
theme={resolvedTheme === "dark" ? "fluksio-dark" : "fluksio-light"}
|
||||
value={value}
|
||||
onChange={(next) => onChange(next ?? "")}
|
||||
loading={<Skeleton className="h-full w-full rounded-md" />}
|
||||
options={{
|
||||
fontFamily,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
lineNumbersMinChars: 3,
|
||||
padding: { top: 12, bottom: 12 },
|
||||
renderLineHighlight: "line",
|
||||
overviewRulerLanes: 0,
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
|
||||
import { nodeSourceQueryOptions } from "./queries"
|
||||
|
||||
const NodeEditor = lazy(() => import("./NodeEditor"))
|
||||
|
||||
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
|
||||
|
||||
const SECTION =
|
||||
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
||||
|
||||
/** Same grammar as the shared `slideUp`, on the axis this panel travels. */
|
||||
const panelSlide = {
|
||||
hidden: { opacity: 0, x: 16 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: duration.base, ease: easeEmphasized },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 16,
|
||||
transition: { duration: duration.fast, ease: easeStandard },
|
||||
},
|
||||
}
|
||||
|
||||
function PortList({
|
||||
title,
|
||||
specs,
|
||||
flow,
|
||||
emptyHint,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
specs: MessageSpec[]
|
||||
flow: string
|
||||
emptyHint: string
|
||||
onChange: (next: MessageSpec[]) => void
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<MessageSpec>) => {
|
||||
const next = specs.map((spec, i) =>
|
||||
i === index ? { ...spec, ...patch } : spec,
|
||||
)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={SECTION}>{title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => onChange([...specs, { name: "", dtype: "float" }])}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{specs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyHint}</p>
|
||||
) : null}
|
||||
|
||||
{specs.map((spec, index) => (
|
||||
<div key={`port-${index}`} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={spec.name ?? ""}
|
||||
placeholder={`name in ${flow}`}
|
||||
aria-label="Message name"
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={(event) =>
|
||||
update(index, { name: event.target.value, port: "" })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={spec.dtype ?? "float"}
|
||||
onValueChange={(value) => update(index, { dtype: value as DType })}
|
||||
>
|
||||
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DTYPES.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove port"
|
||||
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small form built from the node type's declared parameters. */
|
||||
function ParamsForm({
|
||||
schema,
|
||||
params,
|
||||
onChange,
|
||||
}: {
|
||||
schema: Record<string, unknown> | undefined
|
||||
params: Record<string, unknown>
|
||||
onChange: (next: Record<string, unknown>) => void
|
||||
}) {
|
||||
const properties = (schema?.properties ?? {}) as Record<
|
||||
string,
|
||||
{ type?: string; title?: string; default?: unknown }
|
||||
>
|
||||
const entries = Object.entries(properties)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const set = (key: string, value: unknown) =>
|
||||
onChange({ ...params, [key]: value })
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<span className={SECTION}>Settings</span>
|
||||
{entries.map(([key, property]) => {
|
||||
const value = params[key] ?? property.default ?? ""
|
||||
const label = property.title ?? key
|
||||
|
||||
if (property.type === "boolean") {
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`param-${key}`}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => set(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (property.type === "object" || property.type === "array") {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric =
|
||||
property.type === "integer" || property.type === "number"
|
||||
return (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`param-${key}`}
|
||||
className="h-8 text-sm"
|
||||
type={numeric ? "number" : "text"}
|
||||
value={String(value)}
|
||||
onChange={(event) =>
|
||||
set(
|
||||
key,
|
||||
numeric ? Number(event.target.value) : event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelBody({
|
||||
node,
|
||||
flow,
|
||||
nodeType,
|
||||
onChange,
|
||||
onSaveSource,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
node: NodeDef_Input
|
||||
flow: string
|
||||
nodeType: NodeTypeInfo | undefined
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const hasSource = nodeType?.has_source ?? node.type === "python"
|
||||
const { data: source } = useQuery({
|
||||
...nodeSourceQueryOptions(flow, node.id),
|
||||
enabled: hasSource,
|
||||
})
|
||||
|
||||
const [code, setCode] = useState<string | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<string | null>(null)
|
||||
const save = useRef(onSaveSource)
|
||||
save.current = onSaveSource
|
||||
|
||||
const editCode = (next: string) => {
|
||||
setCode(next)
|
||||
pending.current = next
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = null
|
||||
save.current(next)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Closing the panel must not lose the last keystrokes.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
if (pending.current !== null) save.current(pending.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Input
|
||||
value={node.title || node.id}
|
||||
aria-label="Node name"
|
||||
className="h-8 flex-1 text-sm font-medium"
|
||||
onChange={(event) => onChange({ ...node, title: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="grid gap-5 p-4">
|
||||
<PortList
|
||||
title="Consumes"
|
||||
specs={node.requires ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node reads."
|
||||
onChange={(requires) => onChange({ ...node, requires })}
|
||||
/>
|
||||
<PortList
|
||||
title="Provides"
|
||||
specs={node.provides ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
onChange={(provides) => onChange({ ...node, provides })}
|
||||
/>
|
||||
<ParamsForm
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
onChange={(params) => onChange({ ...node, params })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSource ? (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
|
||||
<span className={SECTION}>Code</span>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full w-full animate-pulse bg-muted" />
|
||||
}
|
||||
>
|
||||
<NodeEditor
|
||||
value={code ?? source?.code ?? ""}
|
||||
onChange={editCode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete node
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Node settings, floating over the canvas so the graph stays visible and live.
|
||||
* On a phone there is no room for that, so it becomes a full-screen sheet.
|
||||
*/
|
||||
export function NodePanel({
|
||||
node,
|
||||
flow,
|
||||
nodeTypes,
|
||||
onChange,
|
||||
onSaveSource,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
node: NodeDef_Input | null
|
||||
flow: string
|
||||
nodeTypes: NodeTypeInfo[]
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [node, onClose])
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={Boolean(node)} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
// The panel header carries its own close button, and opening should
|
||||
// not drop the caret into the node's name.
|
||||
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetTitle className="sr-only">Node settings</SheetTitle>
|
||||
{node ? (
|
||||
<PanelBody
|
||||
key={node.id}
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{node ? (
|
||||
<motion.aside
|
||||
key={node.id}
|
||||
variants={panelSlide}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
role="complementary"
|
||||
aria-label="Node settings"
|
||||
data-testid="node-panel"
|
||||
className="pointer-events-auto absolute inset-y-4 right-4 z-10 flex w-[400px] flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
|
||||
>
|
||||
<PanelBody
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</motion.aside>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Edge } from "@xyflow/react"
|
||||
|
||||
import type { MessageSpec, NodeDef_Input } from "@/client"
|
||||
|
||||
/** Resolve a message name against its flow: bare names belong to this flow. */
|
||||
export function qualify(flow: string, name: string): string {
|
||||
if (!name) return ""
|
||||
return name.includes(".") ? name : `${flow}.${name}`
|
||||
}
|
||||
|
||||
/** The flow a qualified message belongs to. */
|
||||
export function flowOf(message: string): string {
|
||||
return message.split(".", 1)[0]
|
||||
}
|
||||
|
||||
/** How a message reads inside its own flow. */
|
||||
export function displayName(flow: string, message: string): string {
|
||||
return message.startsWith(`${flow}.`)
|
||||
? message.slice(flow.length + 1)
|
||||
: message
|
||||
}
|
||||
|
||||
export function portOf(spec: MessageSpec): string {
|
||||
return spec.port || spec.name?.split(".").pop() || ""
|
||||
}
|
||||
|
||||
export type FlowEdgeData = {
|
||||
message: string
|
||||
flow: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn name bindings into canvas edges.
|
||||
*
|
||||
* Nodes never point at each other: a node declares the messages it consumes,
|
||||
* and every node providing that message is upstream of it. Two producers of one
|
||||
* message therefore draw two edges converging on the same input.
|
||||
*/
|
||||
export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
|
||||
const producers = new Map<string, { node: string; port: string }[]>()
|
||||
|
||||
for (const node of nodes) {
|
||||
for (const spec of node.provides ?? []) {
|
||||
const message = qualify(flow, spec.name ?? "")
|
||||
if (!message) continue
|
||||
const list = producers.get(message) ?? []
|
||||
list.push({ node: node.id, port: portOf(spec) })
|
||||
producers.set(message, list)
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = []
|
||||
for (const node of nodes) {
|
||||
for (const spec of node.requires ?? []) {
|
||||
const message = qualify(flow, spec.name ?? "")
|
||||
if (!message) continue
|
||||
const targetPort = portOf(spec)
|
||||
for (const producer of producers.get(message) ?? []) {
|
||||
if (producer.node === node.id) continue
|
||||
edges.push({
|
||||
id: `${producer.node}:${producer.port}->${node.id}:${targetPort}`,
|
||||
source: producer.node,
|
||||
sourceHandle: producer.port,
|
||||
target: node.id,
|
||||
targetHandle: targetPort,
|
||||
type: "live",
|
||||
data: { message, flow } satisfies FlowEdgeData,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
/**
|
||||
* A cheap fingerprint of everything edges depend on, so dragging a node (which
|
||||
* replaces the array every frame) does not re-derive them.
|
||||
*/
|
||||
export function bindingsKey(nodes: NodeDef_Input[]): string {
|
||||
return nodes
|
||||
.map(
|
||||
(node) =>
|
||||
`${node.id}|${(node.requires ?? [])
|
||||
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
|
||||
.join(",")}|${(node.provides ?? [])
|
||||
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
|
||||
.join(",")}`,
|
||||
)
|
||||
.join(";")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* React Flow, routed through the design tokens.
|
||||
*
|
||||
* Scoped to `.react-flow` rather than added to index.css, so the canvas needs no
|
||||
* tokens of its own and follows the theme in both modes without a `colorMode`
|
||||
* prop. See the root DESIGN-GUIDELINES.md → Shells.
|
||||
*/
|
||||
|
||||
.react-flow {
|
||||
--xy-background-color: var(--background);
|
||||
--xy-background-pattern-dots-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground) 30%,
|
||||
transparent
|
||||
);
|
||||
--xy-edge-stroke: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground) 55%,
|
||||
transparent
|
||||
);
|
||||
--xy-edge-stroke-selected: var(--primary);
|
||||
--xy-edge-stroke-width: 1.5;
|
||||
--xy-handle-background-color: var(--card);
|
||||
--xy-handle-border-color: var(--muted-foreground);
|
||||
--xy-selection-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--primary) 8%,
|
||||
transparent
|
||||
);
|
||||
--xy-selection-border: 1px solid var(--primary);
|
||||
--xy-attribution-background-color: transparent;
|
||||
}
|
||||
|
||||
/* A message arriving lights its edge, then decays back to rest. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.react-flow__edge-path.edge-live {
|
||||
animation: edge-pulse var(--duration-slow) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
@keyframes edge-pulse {
|
||||
from {
|
||||
stroke: var(--primary);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
to {
|
||||
stroke: var(--xy-edge-stroke);
|
||||
stroke-width: var(--xy-edge-stroke-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Handles are neutral: the one brand-secondary affordance here is Run. */
|
||||
.react-flow__handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.react-flow__handle.unbound {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--muted-foreground) 40%, transparent);
|
||||
}
|
||||
|
||||
.react-flow__handle-connecting {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.react-flow__node:focus-visible,
|
||||
.react-flow__node:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
/**
|
||||
* Live engine state, deliberately outside React Query.
|
||||
*
|
||||
* Values can arrive many times a second. Keeping them here, with one subscriber
|
||||
* set per key, means a changing value re-renders the chip showing it and
|
||||
* nothing else.
|
||||
*/
|
||||
|
||||
export type LiveValue = { value: unknown; ts: number | null }
|
||||
export type LiveStatus = {
|
||||
status: "active" | "error" | "running" | "success"
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
const values = new Map<string, LiveValue>()
|
||||
const statuses = new Map<string, LiveStatus>()
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
|
||||
let connected = false
|
||||
const connectionListeners = new Set<Listener>()
|
||||
|
||||
function notify(key: string) {
|
||||
for (const listener of listeners.get(key) ?? []) listener()
|
||||
}
|
||||
|
||||
function subscribeKey(key: string, listener: Listener) {
|
||||
let set = listeners.get(key)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
listeners.set(key, set)
|
||||
}
|
||||
set.add(listener)
|
||||
return () => {
|
||||
set.delete(listener)
|
||||
if (set.size === 0) listeners.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export const liveStore = {
|
||||
setValue(name: string, value: LiveValue) {
|
||||
values.set(name, value)
|
||||
notify(`value:${name}`)
|
||||
},
|
||||
setValues(entries: Record<string, LiveValue>) {
|
||||
for (const [name, value] of Object.entries(entries)) {
|
||||
values.set(name, value)
|
||||
notify(`value:${name}`)
|
||||
}
|
||||
},
|
||||
getValue(name: string) {
|
||||
return values.get(name)
|
||||
},
|
||||
setStatus(nodeId: string, status: LiveStatus) {
|
||||
statuses.set(nodeId, status)
|
||||
notify(`status:${nodeId}`)
|
||||
},
|
||||
setStatuses(
|
||||
entries: { id: string; status: string; error?: string | null }[],
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
statuses.set(entry.id, {
|
||||
status: entry.status as LiveStatus["status"],
|
||||
error: entry.error,
|
||||
})
|
||||
notify(`status:${entry.id}`)
|
||||
}
|
||||
},
|
||||
getStatus(nodeId: string) {
|
||||
return statuses.get(nodeId)
|
||||
},
|
||||
setConnected(next: boolean) {
|
||||
if (connected === next) return
|
||||
connected = next
|
||||
for (const listener of connectionListeners) listener()
|
||||
},
|
||||
isConnected() {
|
||||
return connected
|
||||
},
|
||||
reset() {
|
||||
for (const key of values.keys()) notify(`value:${key}`)
|
||||
values.clear()
|
||||
for (const key of statuses.keys()) notify(`status:${key}`)
|
||||
statuses.clear()
|
||||
},
|
||||
}
|
||||
|
||||
export function useLiveValue(name: string | undefined): LiveValue | undefined {
|
||||
return useSyncExternalStore(
|
||||
(listener) => (name ? subscribeKey(`value:${name}`, listener) : () => {}),
|
||||
() => (name ? values.get(name) : undefined),
|
||||
)
|
||||
}
|
||||
|
||||
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeKey(`status:${nodeId}`, listener),
|
||||
() => statuses.get(nodeId),
|
||||
)
|
||||
}
|
||||
|
||||
export function useLiveConnection(): boolean {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
connectionListeners.add(listener)
|
||||
return () => connectionListeners.delete(listener)
|
||||
},
|
||||
() => connected,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Monaco, self-hosted and themed from the design tokens.
|
||||
*
|
||||
* Only the base editor and the Python grammar are pulled in; the other seventy
|
||||
* languages would triple the chunk for nothing. This module is imported by
|
||||
* NodeEditor alone, which the panel loads lazily.
|
||||
*/
|
||||
import { loader } from "@monaco-editor/react"
|
||||
import * as monaco from "monaco-editor/editor/editor.api"
|
||||
// Only Python registers itself; `basic-languages` would pull in all seventy.
|
||||
import "monaco-editor/languages/definitions/python/register"
|
||||
import editorWorker from "monaco-editor/editor/editor.worker?worker"
|
||||
|
||||
let configured = false
|
||||
|
||||
function defineThemes() {
|
||||
const themes = [
|
||||
{ id: "fluksio-light", base: "vs" as const },
|
||||
{ id: "fluksio-dark", base: "vs-dark" as const },
|
||||
]
|
||||
|
||||
for (const theme of themes) {
|
||||
const dark = theme.id.endsWith("dark")
|
||||
const background = dark ? "#1a1a1a" : "#ffffff"
|
||||
const foreground = dark ? "#f5f5f5" : "#333232"
|
||||
const muted = dark ? "#a3a3a3" : "#6b6b6b"
|
||||
const accent = dark ? "#262626" : "#f5f5f5"
|
||||
const primary = dark ? "#7ba3b8" : "#4a7189"
|
||||
const success = dark ? "#87b596" : "#5e8b6d"
|
||||
|
||||
monaco.editor.defineTheme(theme.id, {
|
||||
base: theme.base,
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: primary.slice(1) },
|
||||
{ token: "string", foreground: success.slice(1) },
|
||||
{ token: "comment", foreground: muted.slice(1), fontStyle: "italic" },
|
||||
{ token: "number", foreground: foreground.slice(1) },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": background,
|
||||
"editor.foreground": foreground,
|
||||
"editorLineNumber.foreground": muted,
|
||||
"editorLineNumber.activeForeground": foreground,
|
||||
"editor.lineHighlightBackground": accent,
|
||||
"editorCursor.foreground": primary,
|
||||
"editor.selectionBackground": `${primary}33`,
|
||||
"editorIndentGuide.background1": accent,
|
||||
"editorWidget.background": background,
|
||||
"editorGutter.background": background,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function setupMonaco(): typeof monaco {
|
||||
if (configured) return monaco
|
||||
configured = true
|
||||
|
||||
// Self-hosted: no CDN fetch, so the editor works on a LAN install.
|
||||
self.MonacoEnvironment = { getWorker: () => new editorWorker() }
|
||||
loader.config({ monaco })
|
||||
defineThemes()
|
||||
return monaco
|
||||
}
|
||||
|
||||
export const monacoFontFamily = (): string =>
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--font-mono")
|
||||
.trim() || "ui-monospace, monospace"
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type UseMutationResult,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { type FlowDef_Input, FlowsService } from "@/client"
|
||||
|
||||
export const flowKeys = {
|
||||
all: ["flows"] as const,
|
||||
detail: (name: string) => ["flows", name] as const,
|
||||
source: (name: string, nodeId: string) =>
|
||||
["flows", name, "source", nodeId] as const,
|
||||
nodeTypes: ["flows", "node-types"] as const,
|
||||
}
|
||||
|
||||
export const flowsQueryOptions = () => ({
|
||||
queryKey: flowKeys.all,
|
||||
queryFn: () => FlowsService.readFlows(),
|
||||
})
|
||||
|
||||
export const flowQueryOptions = (name: string) => ({
|
||||
queryKey: flowKeys.detail(name),
|
||||
queryFn: () => FlowsService.readFlow({ name }),
|
||||
})
|
||||
|
||||
export const nodeTypesQueryOptions = () => ({
|
||||
queryKey: flowKeys.nodeTypes,
|
||||
queryFn: () => FlowsService.readNodeTypes(),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
|
||||
export const nodeSourceQueryOptions = (name: string, nodeId: string) => ({
|
||||
queryKey: flowKeys.source(name, nodeId),
|
||||
queryFn: () => FlowsService.readNodeSource({ name, nodeId }),
|
||||
})
|
||||
|
||||
const AUTOSAVE_DELAY = 800
|
||||
|
||||
/**
|
||||
* Saves the flow a moment after the last edit, and immediately when the editor
|
||||
* needs the server to be current (closing a panel, switching flow, running).
|
||||
*
|
||||
* Identical documents are skipped server-side, so a quiet canvas writes nothing.
|
||||
*/
|
||||
export function useAutosave(name: string): {
|
||||
save: (definition: FlowDef_Input) => void
|
||||
flush: () => void
|
||||
mutation: UseMutationResult<unknown, unknown, FlowDef_Input, unknown>
|
||||
} {
|
||||
const queryClient = useQueryClient()
|
||||
const pending = useRef<FlowDef_Input | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (definition: FlowDef_Input) =>
|
||||
FlowsService.saveFlow({ name, requestBody: definition }),
|
||||
onSuccess: (detail) => {
|
||||
// Write the server's answer straight into the cache: invalidating would
|
||||
// pull the document back out from under edits still in flight.
|
||||
queryClient.setQueryData(flowKeys.detail(name), detail)
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
|
||||
},
|
||||
})
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
const definition = pending.current
|
||||
pending.current = null
|
||||
if (definition) {
|
||||
mutation.mutate(definition)
|
||||
}
|
||||
}, [mutation])
|
||||
|
||||
const save = useCallback(
|
||||
(definition: FlowDef_Input) => {
|
||||
pending.current = definition
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(flush, AUTOSAVE_DELAY)
|
||||
},
|
||||
[flush],
|
||||
)
|
||||
|
||||
// Leaving the tab is the last chance to persist what is still queued.
|
||||
useEffect(() => {
|
||||
const onHidden = () => {
|
||||
if (document.visibilityState === "hidden") flush()
|
||||
}
|
||||
document.addEventListener("visibilitychange", onHidden)
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onHidden)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
}
|
||||
}, [flush])
|
||||
|
||||
return { save, flush, mutation }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { liveStore } from "./liveStore"
|
||||
|
||||
const RECONNECT_MIN = 1000
|
||||
const RECONNECT_MAX = 30000
|
||||
|
||||
type FlowEvent =
|
||||
| {
|
||||
type: "snapshot"
|
||||
values: Record<string, { value: unknown; ts: number | null }>
|
||||
nodes: { id: string; status: string; error?: string | null }[]
|
||||
}
|
||||
| { type: "message_value"; name: string; value: unknown; ts: number }
|
||||
| { type: "node_executed"; node: string }
|
||||
| { type: "node_error"; node: string; error: string }
|
||||
| { type: "node_status"; node: string; status: string; error?: string | null }
|
||||
| {
|
||||
type: "pipeline_rebuilt"
|
||||
nodes: { id: string; status: string; error?: string | null }[]
|
||||
}
|
||||
|
||||
function socketUrl(): string {
|
||||
const base = String(OpenAPI.BASE || window.location.origin)
|
||||
const url = new URL("/api/v1/flows/ws", base)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
// Browsers cannot set headers on a websocket handshake, so the token rides
|
||||
// in the query string.
|
||||
url.searchParams.set("token", localStorage.getItem("access_token") ?? "")
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps one socket open for the editor, feeding the live store.
|
||||
*
|
||||
* @param onAuthFailure called when the server rejects the token, so the caller
|
||||
* can send the user back to the login screen.
|
||||
*/
|
||||
export function useFlowSocket(onAuthFailure?: () => void): void {
|
||||
const socket = useRef<WebSocket | null>(null)
|
||||
const retry = useRef(RECONNECT_MIN)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
closed.current = false
|
||||
|
||||
const connect = () => {
|
||||
if (closed.current) return
|
||||
const ws = new WebSocket(socketUrl())
|
||||
socket.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
retry.current = RECONNECT_MIN
|
||||
liveStore.setConnected(true)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message: FlowEvent = JSON.parse(event.data)
|
||||
switch (message.type) {
|
||||
case "snapshot":
|
||||
liveStore.setValues(message.values)
|
||||
liveStore.setStatuses(message.nodes)
|
||||
break
|
||||
case "message_value":
|
||||
liveStore.setValue(message.name, {
|
||||
value: message.value,
|
||||
ts: message.ts,
|
||||
})
|
||||
break
|
||||
case "node_executed":
|
||||
liveStore.setStatus(message.node, { status: "success" })
|
||||
break
|
||||
case "node_error":
|
||||
liveStore.setStatus(message.node, {
|
||||
status: "error",
|
||||
error: message.error,
|
||||
})
|
||||
break
|
||||
case "node_status":
|
||||
liveStore.setStatus(message.node, {
|
||||
status: message.status as "active" | "error",
|
||||
error: message.error,
|
||||
})
|
||||
break
|
||||
case "pipeline_rebuilt":
|
||||
liveStore.setStatuses(message.nodes)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
liveStore.setConnected(false)
|
||||
if (closed.current) return
|
||||
if (event.code === 1008) {
|
||||
onAuthFailure?.()
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(connect, retry.current)
|
||||
retry.current = Math.min(retry.current * 2, RECONNECT_MAX)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
closed.current = true
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
socket.current?.close()
|
||||
liveStore.setConnected(false)
|
||||
}
|
||||
}, [onAuthFailure])
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Briefcase, Home, Users } from "lucide-react"
|
||||
import { Briefcase, Home, Users, Workflow } from "lucide-react"
|
||||
|
||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||
import { Logo } from "@/components/Common/Logo"
|
||||
@@ -14,6 +14,7 @@ import { User } from "./User"
|
||||
|
||||
const baseItems: Item[] = [
|
||||
{ icon: Home, title: "Dashboard", path: "/" },
|
||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||
{ icon: Briefcase, title: "Items", path: "/items" },
|
||||
]
|
||||
|
||||
|
||||
@@ -36,7 +36,10 @@ export function Main({ items }: MainProps) {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive = currentPath === item.path
|
||||
const isActive =
|
||||
item.path === "/"
|
||||
? currentPath === "/"
|
||||
: currentPath.startsWith(item.path)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-e2 outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
Reference in New Issue
Block a user