import { useQuery } from "@tanstack/react-query" import { Maximize2, Minimize2, X } from "lucide-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 { Command, CommandEmpty, CommandGroup, CommandItem, CommandList, } from "@/components/ui/command" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { nodeSourceQueryOptions } from "./queries" import { PANEL_SECTION, SidePanel } from "./SidePanel" const NodeEditor = lazy(() => import("./NodeEditor")) const DTYPES: DType[] = ["float", "int", "str", "bool", "json"] const SECTION = PANEL_SECTION /** * A message name, typed freely or picked from the names already in play. * * The suggestions are the point: a message only connects when both ends spell * it the same way, so choosing beats typing. */ function MessageNameInput({ value, suggestions, placeholder, autoFocus, onChange, }: { value: string suggestions: string[] placeholder: string autoFocus: boolean onChange: (next: string) => void }) { const [open, setOpen] = useState(false) const matches = suggestions.filter( (name) => name !== value && name.toLowerCase().includes(value.toLowerCase()), ) return ( 0} onOpenChange={setOpen}> setOpen(true)} onBlur={() => setOpen(false)} onChange={(event) => { onChange(event.target.value) setOpen(true) }} onKeyDown={(event) => { if (event.key === "Escape") setOpen(false) }} /> event.preventDefault()} > No matching message. {matches.map((name) => ( { event.preventDefault() onChange(name) setOpen(false) }} onSelect={() => { onChange(name) setOpen(false) }} > {name} ))} ) } function PortList({ title, specs, flow, emptyHint, suggestions, onChange, }: { title: string specs: MessageSpec[] flow: string emptyHint: string suggestions: string[] onChange: (next: MessageSpec[]) => void }) { // The port just added, so its name field can take focus. const [freshIndex, setFreshIndex] = useState(null) const update = (index: number, patch: Partial) => { const next = specs.map((spec, i) => i === index ? { ...spec, ...patch } : spec, ) onChange(next) } return (
{title}
{specs.length === 0 ? (

{emptyHint}

) : null} {specs.map((spec, index) => (
update(index, { name, port: "" })} />
))}
) } /** A small form built from the node type's declared parameters. */ function ParamsForm({ schema, params, onChange, }: { schema: Record | undefined params: Record onChange: (next: Record) => 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 (
Settings {entries.map(([key, property]) => { const value = params[key] ?? property.default ?? "" const label = property.title ?? key if (property.type === "boolean") { return (
set(key, checked)} />
) } if (property.type === "object" || property.type === "array") { return null } const numeric = property.type === "integer" || property.type === "number" return (
set( key, numeric ? Number(event.target.value) : event.target.value, ) } />
) })}
) } function PanelBody({ node, flow, nodeType, suggestions, expanded, onChange, onSaveSource, onToggleExpand, }: { node: NodeDef_Input flow: string nodeType: NodeTypeInfo | undefined suggestions: PortSuggestions expanded: boolean onChange: (next: NodeDef_Input) => void onSaveSource: (code: string) => void onToggleExpand: () => void }) { const hasSource = nodeType?.has_source ?? node.type === "python" const { data: source } = useQuery({ ...nodeSourceQueryOptions(flow, node.id), enabled: hasSource, }) const [code, setCode] = useState(null) const timer = useRef | null>(null) const pending = useRef(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 ( <>
onChange({ ...node, requires })} /> onChange({ ...node, provides })} /> onChange({ ...node, params })} />
{hasSource ? (
Code
} >
) : null} ) } /** * 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. */ /** Message names worth offering on each side of a node. */ export type PortSuggestions = { consumes: string[]; provides: string[] } export function NodePanel({ node, flow, nodeTypes, suggestions, expanded, onChange, onSaveSource, onToggleExpand, onClose, onDelete, }: { node: NodeDef_Input | null flow: string nodeTypes: NodeTypeInfo[] suggestions: PortSuggestions expanded: boolean onChange: (next: NodeDef_Input) => void onSaveSource: (code: string) => void onToggleExpand: () => void onClose: () => void onDelete: () => void }) { const nodeType = nodeTypes.find((entry) => entry.type === node?.type) return ( onChange({ ...node, title: event.target.value }) } /> ) : null } footer={ } > {node ? ( ) : null} ) }