import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Maximize2, Minimize2, X } from "lucide-react" import { type ComponentProps, lazy, Suspense, useEffect, useRef, useState, } from "react" import { type DType, FlowsService, type MessageSpec, type NodeDef_Input, type 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 useCustomToast from "@/hooks/useCustomToast" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { MessageSparkline } from "./MessageSparkline" import { flowKeys, libraryQueryOptions, nodeSourceQueryOptions, secretsQueryOptions, useParamSuggestions, } from "./queries" import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel" const NodeEditor = lazy(() => import("./NodeEditor")) const DTYPES: DType[] = [ "float", "int", "str", "bool", "json", "series", "record", "list", ] /** What a list may hold. One declared level: no list of lists. */ const ITEM_DTYPES: DType[] = ["record", "float", "int", "str", "bool", "json"] /** Radix selects cannot hold an empty value, so "no secret" needs a name. */ const NO_SECRET = "__none__" const SECTION = PANEL_SECTION /** Settings the engine reads itself, so they are not the author's to name. */ const RESERVED_PARAMS = new Set(["synchronous"]) /** * A text field that offers what is already in use elsewhere. * * Typing stays free — the list is a shortcut, never a constraint — but picking * beats typing wherever two things only work together when they match. */ function SuggestInput({ value, suggestions, empty, onChange, onFocus, onBlur, ...props }: { value: string suggestions: string[] /** What to say when nothing matches what is typed. */ empty: string onChange: (next: string) => void } & Omit, "value" | "onChange">) { const [open, setOpen] = useState(false) const matches = suggestions.filter( (item) => item !== value && item.toLowerCase().includes(value.toLowerCase()), ) return ( 0} onOpenChange={setOpen}> { setOpen(true) onFocus?.(event) }} onBlur={(event) => { setOpen(false) onBlur?.(event) }} onChange={(event) => { onChange(event.target.value) setOpen(true) }} onKeyDown={(event) => { if (event.key === "Escape") setOpen(false) }} /> event.preventDefault()} > {empty} {matches.map((item) => ( { event.preventDefault() onChange(item) setOpen(false) }} onSelect={() => { onChange(item) setOpen(false) }} > {item} ))} ) } /** * 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, onRenamed, }: { value: string suggestions: string[] placeholder: string autoFocus: boolean onChange: (next: string) => void /** The name as it was before this edit, once the field is done with. */ onRenamed?: (previous: string, next: string) => void }) { // Every keystroke commits, so a rename is only a rename once the user is // finished with the field. const before = useRef(value) return ( { before.current = value }} onBlur={() => { if (before.current !== value) onRenamed?.(before.current, value) before.current = value }} /> ) } function PortList({ title, specs, flow, emptyHint, suggestions, onChange, onRenamed, }: { title: string specs: MessageSpec[] flow: string emptyHint: string suggestions: string[] onChange: (next: MessageSpec[]) => void onRenamed?: (previous: string, next: string) => 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) => ( // The curve reads as its own thing rather than as part of the row // above it, so it gets a little air.
update(index, { name, port: "" })} onRenamed={onRenamed} /> {spec.dtype === "list" ? ( ) : null} update(index, { interval: Number(event.target.value) || 0 }) } />
{spec.name ? : null}
))}
) } /** The value types a free-form setting can hold, and how to read one back. */ const FREE_TYPES = ["text", "number", "on/off", "json"] as const type FreeType = (typeof FREE_TYPES)[number] function freeTypeOf(value: unknown): FreeType { if (typeof value === "boolean") return "on/off" if (typeof value === "number") return "number" if (value !== null && typeof value === "object") return "json" return "text" } function castTo(type: FreeType, raw: string): unknown { if (type === "number") return Number(raw) || 0 if (type === "on/off") return raw === "true" if (type === "json") { try { return JSON.parse(raw) } catch { // Half-typed JSON is normal while editing; keep the text until it parses. return raw } } return raw } function asText(value: unknown): string { if (value === null || value === undefined) return "" if (typeof value === "object") return JSON.stringify(value) return String(value) } /** * Settings a node type does not declare. * * A function node's parameters are its author's to name — they arrive in * `process` as whatever was put here — so there is no schema to render and the * keys are typed in alongside the values. */ function FreeParamsForm({ params, reserved, onChange, }: { params: Record reserved: Set onChange: (next: Record) => void }) { const [freshKey, setFreshKey] = useState(null) const entries = Object.entries(params).filter(([key]) => !reserved.has(key)) const rename = (from: string, to: string) => { if (to === from) return // Rebuilt rather than patched, so the settings keep the order they were // typed in instead of jumping around as one is renamed. const next: Record = {} for (const [key, value] of Object.entries(params)) { next[key === from ? to : key] = value } onChange(next) } return (
Settings
{entries.length === 0 ? (

Values your code reads from params.

) : null} {entries.map(([key, value], index) => { const type = freeTypeOf(value) return ( // Keyed by position, not by name: renaming a setting must not // remount its row and take the half-typed value with it.
rename(key, event.target.value.trim() || key)} /> {type === "on/off" ? ( onChange({ ...params, [key]: checked }) } /> ) : ( onChange({ ...params, [key]: castTo(type, event.target.value), }) } /> )}
) })}
) } /** * The cron expression that fires as often as `seconds` asks for, if there is * one. Only divisors of an hour or a day line up — a step of seven minutes * would jump from :56 back to :00, which is not every seven minutes. */ function cronFromInterval(seconds: unknown): string | null { const value = Number(seconds) if (!Number.isFinite(value) || value <= 0) return null const minutes = value / 60 // Cron's finest grain is the minute. if (!Number.isInteger(minutes)) return null if (minutes === 1) return "* * * * *" if (minutes < 60) return 60 % minutes === 0 ? `*/${minutes} * * * *` : null const hours = minutes / 60 if (!Number.isInteger(hours)) return null if (hours === 1) return "0 * * * *" if (hours < 24) return 24 % hours === 0 ? `0 */${hours} * * *` : null return hours === 24 ? "0 0 * * *" : null } /** What the five fields mean, and the schedule the interval beside them asks for. */ function CronHelp({ params, onPick, }: { params: Record onPick: (expression: string) => void }) { const derived = cronFromInterval(params.interval) return ( <>

Five fields —{" "} minute hour day-of-month month weekday . * is every,{" "} */5 every fifth,{" "} 1-5 a range.

{derived && params.cron !== derived ? ( ) : null} ) } /** A small form built from the node type's declared parameters. */ function ParamsForm({ type, schema, params, onChange, }: { type: string | undefined schema: Record | undefined params: Record onChange: (next: Record) => void }) { const properties = (schema?.properties ?? {}) as Record< string, { type?: string title?: string description?: string default?: unknown "x-secret"?: boolean } > const entries = Object.entries(properties) const { data: secretList } = useQuery(secretsQueryOptions()) const secrets = secretList?.data ?? [] // A type with nothing to fill in has nothing to suggest, and the other flows // are not worth reading for it. const suggestions = useParamSuggestions(entries.length ? type : undefined) 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)} />
) } // A credential is stored once and referenced, so it never ends up in // flow.json where the whole team can read it. if (property["x-secret"]) { const reference = (params[key] ?? null) as { $secret?: string } | null return (

{secrets.length ? property.description || "Stored in the secrets store, never in the flow file." : "No secrets stored yet."}

) } if (property.type === "object" || property.type === "array") { return null } const numeric = property.type === "integer" || property.type === "number" return (
set(key, numeric ? Number(next) : next)} /> {property.description ? (

{property.description}

) : null} {key === "cron" ? ( set(key, expression)} /> ) : null}
) })}
) } /** * Sharing a node moves its code to the library, where other flows can point at * it. Each flow keeps its own ports and settings; only the code is common, so * one fix reaches all of them. */ function SharingSection({ flow, node, onShared, }: { flow: string node: NodeDef_Input onShared: () => void }) { const queryClient = useQueryClient() const { showErrorToast } = useCustomToast() const { data: library } = useQuery(libraryQueryOptions()) const [name, setName] = useState("") const shared = node.source_ref const usages = library?.find((entry) => entry.name === shared)?.used_by ?? [] const done = () => { queryClient.invalidateQueries({ queryKey: flowKeys.library }) queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) }) onShared() } const share = useMutation({ mutationFn: (libName: string) => FlowsService.shareNode({ name: flow, nodeId: node.id, requestBody: { lib_name: libName }, }), onSuccess: done, onError: () => showErrorToast("That name is taken, or is not a valid name."), }) const unshare = useMutation({ mutationFn: () => FlowsService.unshareNode({ name: flow, nodeId: node.id }), onSuccess: done, onError: () => showErrorToast("The node could not be unshared."), }) if (shared) { return (
Shared

Runs {shared} from the library {usages.length > 1 ? `, along with ${usages.length - 1} other node${ usages.length === 2 ? "" : "s" }` : ""} . Editing the code here changes it everywhere.

) } return (
Reuse

Move this node's code to the library so other flows can run it too.

setName(event.target.value)} />
) } /** The value a fresh output returns, so the scaffold runs as written. */ const PLACEHOLDER: Record = { float: "0.0", int: "0", bool: "False", str: '""', json: "{}", series: '{"lines": []}', record: "{}", list: "[]", } const SCAFFOLD_DOC = '"""A new node. Return a dict keyed by your output ports."""' const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") /** * Exactly what `scaffoldFor` writes, for any set of ports — and the source a * node starts life with, which is that with no ports at all. * * Nothing else matches, which is what makes it safe to overwrite: a returned * value that is not one of the placeholders is somebody's code. */ const SCAFFOLD_SHAPE = (() => { const value = Object.values(PLACEHOLDER).map(quote).join("|") const entry = `"[^"]+": (?:${value})` return new RegExp( `^${quote(SCAFFOLD_DOC)}\\n\\n\\ndef process\\(\\w+(?:, \\w+)*\\):\\n return \\{(?:${entry}(?:, ${entry})*)?\\}\\n$`, ) })() /** The name the node function sees; the engine derives it the same way. */ const portName = (spec: MessageSpec) => spec.port || (spec.name ?? "").split(".").pop() || "" /** A `process` that takes this node's inputs and returns its outputs. */ function scaffoldFor(node: NodeDef_Input): string { const args = [ ...new Set( (node.requires ?? []) .map(portName) // Anything else cannot be a keyword argument, so it cannot be a port. .filter((port) => /^[A-Za-z_]\w*$/.test(port) && port !== "params"), ), ] const returns = (node.provides ?? []) .filter((spec) => portName(spec)) .map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`) return `${SCAFFOLD_DOC}\n\n\ndef process(${[...args, "params"].join( ", ", )}):\n return {${returns.join(", ")}}\n` } function PanelBody({ node, flow, nodeType, suggestions, expanded, onChange, onRenameMessage, onSaveSource, onShared, onToggleExpand, }: { node: NodeDef_Input flow: string nodeType: NodeTypeInfo | undefined suggestions: PortSuggestions expanded: boolean onChange: (next: NodeDef_Input) => void onRenameMessage: (previous: string, next: string) => void onSaveSource: (code: string) => void onShared: () => 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 /** Send what is typed now rather than a second from now. */ const saveNow = () => { if (timer.current) { clearTimeout(timer.current) timer.current = null } if (pending.current !== null) save.current(pending.current) } 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) } /** * Change the node, and keep an untouched scaffold in step with its ports. * * Only code that is still exactly what this panel generates is rewritten — * one edit of your own and it is yours, ports or no ports. Shared code is * never rewritten either: the other flows using it have their own ports. */ const editNode = (next: NodeDef_Input) => { onChange(next) const current = code ?? source?.code if (!hasSource || next.source_ref || current === undefined) return const wanted = scaffoldFor(next) if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted) } // ⌘S in the editor means "apply this code"; the editor's own state is here, // so the binding is too. Anywhere else on the canvas it publishes the flow. useShortcuts( { "mod+s": (event) => { if (inCodeEditor(event.target)) saveNow() }, }, ["mod+s"], ) // 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 ( <>
editNode({ ...node, requires })} /> editNode({ ...node, provides })} // Only the publishing side names a message; an input is as often // re-pointed at a different one as it is renamed. onRenamed={onRenameMessage} /> onChange({ ...node, params })} /> {nodeType?.free_params ? ( onChange({ ...node, params })} /> ) : null} {hasSource ? (
onChange({ ...node, timeout: Number(event.target.value) || null, }) } />

Seconds this code may run before it is stopped. Above 60 the engine may deliver the same work again while it is still running.

) : null} {hasSource ? ( ) : null}
{hasSource ? (
{node.source_ref ? `Shared code · ${node.source_ref}` : "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, onRenameMessage, onSaveSource, onShared, onToggleExpand, onClose, onDelete, }: { node: NodeDef_Input | null flow: string nodeTypes: NodeTypeInfo[] suggestions: PortSuggestions expanded: boolean onChange: (next: NodeDef_Input) => void onRenameMessage: (previous: string, next: string) => void onSaveSource: (code: string) => void onShared: () => void onToggleExpand: () => void onClose: () => void onDelete: () => void }) { const nodeType = nodeTypes.find((entry) => entry.type === node?.type) return ( {/* Where the node lives: the flow is the namespace of its messages, so it reads like the folder holding them. */} /{flow}/ onChange({ ...node, title })} /> ) : null } footer={ } > {node ? ( ) : null} ) }