Add undo on the canvas, and sharpen the flow chrome

Deleting a node cost its source with no way back. Every mutation already
funnels through one commit, so undo/redo is a bounded stack of node
snapshots replayed through the same debounced save. Deleting a node only
drops it from flow.json — the source file survives — so restoring the id
restores the code. Renaming a message now offers to follow the rename
across every node still bound to the old name, as one undoable step.

Autosave never fired: flush depended on the whole mutation object, which
react-query rebuilds every render, so the effect re-ran and its cleanup
cancelled the pending timer. Unmounting now flushes rather than drops.

The canvas looked blurry zoomed out because Background scales the dot
radius by zoom, leaving quarter-pixel dots on a drifting tile; radius
and spacing now divide the zoom back out, spacing in octaves so the grid
halves. Edge value labels are opaque, the edge popover fits its summary
on one row with a trash icon and scrolls names that overflow, and flow
settings moved to the flowbar. The flowbar was sized against the
viewport rather than the canvas, so its buttons left the screen once
enough flows were open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
Melvin Strobl
2026-08-15 21:19:53 +02:00
co-authored by Claude Opus 5
parent 6b73ca3645
commit 9cede8dbb5
8 changed files with 410 additions and 75 deletions
+89 -27
View File
@@ -1,8 +1,11 @@
import { ArrowRight } from "lucide-react" import { ArrowRight, Trash2 } from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover" import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { displayName } from "./deriveEdges" import { displayName } from "./deriveEdges"
import { useLiveValue } from "./liveStore" import { useLiveValue } from "./liveStore"
@@ -15,6 +18,60 @@ function relativeTime(ts: number | null | undefined): string {
return `${Math.round(seconds / 3600)}h ago` return `${Math.round(seconds / 3600)}h ago`
} }
/**
* A value short enough to share the summary row, or `null` for the objects and
* arrays that need the payload view instead.
*/
function formatScalar(value: unknown): string | null {
// Three decimals, trailing zeros dropped: enough precision to be useful, never
// wide enough to wrap the row.
if (typeof value === "number") return String(Number(value.toFixed(3)))
if (typeof value === "string") return value
if (typeof value === "boolean" || value === null) return String(value)
return null
}
/**
* Text that scrolls its own overflow into view and back, so a long name stays
* readable without widening the popover. Still text at rest when it fits.
*/
function Marquee({ text, className }: { text: string; className?: string }) {
const ref = useRef<HTMLSpanElement>(null)
const [overflow, setOverflow] = useState(0)
// biome-ignore lint/correctness/useExhaustiveDependencies: a new string is what changes the measurement.
useEffect(() => {
const el = ref.current
if (el) setOverflow(Math.max(0, el.scrollWidth - el.clientWidth))
}, [text])
return (
<span
ref={ref}
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
>
<motion.span
className="inline-block"
animate={{ x: -overflow }}
transition={
overflow
? {
duration: overflow / 25,
ease: "linear",
delay: 1.2,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
repeatDelay: 1.2,
}
: undefined
}
>
{text}
</motion.span>
</span>
)
}
export type InspectedEdge = { export type InspectedEdge = {
message: string message: string
/** Node titles, so the popover names the two ends in the user's own words. */ /** Node titles, so the popover names the two ends in the user's own words. */
@@ -41,6 +98,8 @@ export function EdgeInspector({
const live = useLiveValue(edge?.message) const live = useLiveValue(edge?.message)
if (!edge) return null if (!edge) return null
const scalar = live === undefined ? null : formatScalar(live.value)
return ( return (
<Popover open onOpenChange={(open) => !open && onClose()}> <Popover open onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor <PopoverAnchor
@@ -52,44 +111,47 @@ export function EdgeInspector({
className="w-72 p-3" className="w-72 p-3"
data-testid="edge-inspector" data-testid="edge-inspector"
> >
<p className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<span className="min-w-0 flex-1 truncate font-medium"> <Marquee text={edge.from} className="flex-1 font-medium" />
{edge.from}
</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" /> <ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-right font-medium"> <Marquee text={edge.to} className="flex-1 text-right font-medium" />
{edge.to} <Button
variant="ghost"
size="icon-sm"
className="-my-1 -mr-1 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onUnbind(edge.message)}
aria-label="Disconnect"
>
<Trash2 />
</Button>
</div>
<div className="mt-1.5 flex items-baseline gap-2 text-xs">
<Marquee
text={displayName(flow, edge.message)}
className="flex-1 font-mono text-muted-foreground"
/>
{scalar === null ? null : (
<span className="max-w-[45%] shrink-0 truncate font-mono font-medium">
{scalar}
</span> </span>
</p> )}
<p className="mt-1 font-mono text-xs text-muted-foreground"> <span className="shrink-0 text-muted-foreground">
{displayName(flow, edge.message)} {relativeTime(live?.ts)}
</p> </span>
</div>
{live === undefined ? ( {live === undefined ? (
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Nothing has come through yet. Run the flow to see a value here. Nothing has come through yet. Run the flow to see a value here.
</p> </p>
) : ( ) : scalar === null ? (
<>
<ScrollArea className="mt-2 max-h-48"> <ScrollArea className="mt-2 max-h-48">
<pre className="whitespace-pre-wrap break-all font-mono text-xs"> <pre className="whitespace-pre-wrap break-all font-mono text-xs">
{JSON.stringify(live.value, null, 2)} {JSON.stringify(live.value, null, 2)}
</pre> </pre>
</ScrollArea> </ScrollArea>
<p className="mt-2 text-xs text-muted-foreground"> ) : null}
{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> </PopoverContent>
</Popover> </Popover>
) )
-19
View File
@@ -3,7 +3,6 @@ import {
AlertCircle, AlertCircle,
Loader2, Loader2,
Maximize2, Maximize2,
Pencil,
Play, Play,
Plus, Plus,
ZoomIn, ZoomIn,
@@ -34,14 +33,12 @@ export function FlowDock({
issues, issues,
running, running,
onAddNode, onAddNode,
onEditFlow,
onRun, onRun,
onFocusNode, onFocusNode,
}: { }: {
issues: ValidationIssue[] issues: ValidationIssue[]
running: boolean running: boolean
onAddNode: () => void onAddNode: () => void
onEditFlow: () => void
onRun: () => void onRun: () => void
onFocusNode: (nodeId: string) => void onFocusNode: (nodeId: string) => void
}) { }) {
@@ -71,22 +68,6 @@ export function FlowDock({
<TooltipContent>Add a node (K)</TooltipContent> <TooltipContent>Add a node (K)</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" /> <Separator orientation="vertical" className="mx-0.5 !h-5" />
<Button <Button
+213 -14
View File
@@ -7,6 +7,7 @@ import {
ReactFlowProvider, ReactFlowProvider,
useNodesState, useNodesState,
useReactFlow, useReactFlow,
useStore,
useUpdateNodeInternals, useUpdateNodeInternals,
} from "@xyflow/react" } from "@xyflow/react"
import "@xyflow/react/dist/style.css" import "@xyflow/react/dist/style.css"
@@ -68,6 +69,91 @@ type Rebind = {
dtype: MessageSpec["dtype"] dtype: MessageSpec["dtype"]
} }
/** A finished message rename, waiting on an answer about the rest of the flow. */
type MessageRename = {
from: string
to: string
/** How many other nodes are still bound to the old name. */
count: number
}
/** Documents to step back and forward through, newest last. */
type History = {
past: NodeDef_Input[][]
future: NodeDef_Input[][]
/** When the last entry was recorded, so a burst of typing stays one edit. */
at: number
}
const HISTORY_LIMIT = 50
const TYPING_WINDOW = 500
/**
* Remember the document as it was before a change.
*
* Fields commit on every keystroke, so consecutive edits that leave the same
* nodes in place fold into the entry already on the stack. Anything carrying
* positions — a drag, a new node — is a finished action and starts its own.
*/
function record(
history: History,
previous: NodeDef_Input[],
next: NodeDef_Input[],
settled: boolean,
) {
const sameNodes =
previous.length === next.length &&
previous.every((node, index) => node.id === next[index].id)
const stillTyping =
!settled && sameNodes && Date.now() - history.at < TYPING_WINDOW
if (!stillTyping) {
history.past.push(previous)
if (history.past.length > HISTORY_LIMIT) history.past.shift()
}
history.at = settled ? 0 : Date.now()
// A new change is a new branch: what was undone is not coming back.
history.future = []
}
/** Monaco and every text field keep their own undo, so leave theirs alone. */
function isTextEntry(target: EventTarget | null): boolean {
return Boolean(
(target as Element | null)?.closest?.(
"input, textarea, [contenteditable='true'], .monaco-editor",
),
)
}
function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
return definitions.map((node) => ({
id: node.id,
type: "flow",
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
data: {},
}))
}
/**
* The dot grid lives in flow space, so React Flow shrinks it along with the
* zoom. Below 1:1 that leaves sub-pixel dots on a fractional grid, which the
* canvas can only render as a moiré haze — the whole viewport reads as blurry.
* Taking the zoom back out of both the radius and the spacing gives the same
* crisp 1.5px dots, 24px apart, whatever the zoom is.
*/
function CanvasBackground() {
const zoom = useStore((state) => state.transform[2])
// In octaves, so the grid halves rather than drifting as you zoom.
const step = 2 ** Math.round(Math.log2(1 / zoom))
return (
<Background
variant={BackgroundVariant.Dots}
gap={24 * step}
size={1.5 / zoom}
/>
)
}
/** Step a new node off any node already sitting at that spot. */ /** Step a new node off any node already sitting at that spot. */
function freePosition( function freePosition(
nodes: NodeDef_Input[], nodes: NodeDef_Input[],
@@ -120,6 +206,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(null) const [inspected, setInspected] = useState<InspectedEdge | null>(null)
const [rebind, setRebind] = useState<Rebind | null>(null) const [rebind, setRebind] = useState<Rebind | null>(null)
const [renamed, setRenamed] = useState<MessageRename | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false) const [flowPanelOpen, setFlowPanelOpen] = useState(false)
// The editor at full size covers the canvas, so its chrome steps aside. // The editor at full size covers the canvas, so its chrome steps aside.
const [editorExpanded, setEditorExpanded] = useState(false) const [editorExpanded, setEditorExpanded] = useState(false)
@@ -128,7 +215,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
// Keep the latest document in a ref so autosave never captures a stale copy. // Keep the latest document in a ref so autosave never captures a stale copy.
const latest = useRef<FlowDef_Input>(detail.definition) const latest = useRef<FlowDef_Input>(detail.definition)
const commit = useCallback( const history = useRef<History>({ past: [], future: [], at: 0 })
/** Put a set of nodes on the canvas and on their way to the server. */
const apply = useCallback(
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => { (nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
const placed = nodes.map((node) => { const placed = nodes.map((node) => {
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id) const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
@@ -142,6 +232,53 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
[canvasNodes, detail.definition, save], [canvasNodes, detail.definition, save],
) )
/** Apply a change and make it undoable. */
const commit = useCallback(
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
record(
history.current,
latest.current.nodes ?? [],
nodes,
Boolean(positions),
)
apply(nodes, positions)
},
[apply],
)
/**
* Step through the history. Undo and redo save like any other edit, so the
* autosave debounce collapses a run of them into one write.
*/
const step = useCallback(
(back: boolean) => {
const { past, future } = history.current
const remembered = (back ? past : future).pop()
if (!remembered) return
;(back ? future : past).push(latest.current.nodes ?? [])
history.current.at = 0
// xyflow owns the positions, so hand it the remembered ones too.
const canvas = toCanvasNodes(remembered)
setCanvasNodes(canvas)
apply(remembered, canvas)
setInspected(null)
},
[apply, setCanvasNodes],
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Shift makes it a capital Z, so compare on the letter alone.
const undoKey =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z"
if (!undoKey || isTextEntry(event.target)) return
event.preventDefault()
step(!event.shiftKey)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [step])
const typeLabels = useMemo( const typeLabels = useMemo(
() => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])), () => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])),
[nodeTypeInfo], [nodeTypeInfo],
@@ -418,6 +555,47 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
[commit, definitions, flowName], [commit, definitions, flowName],
) )
/**
* A message name is shared, not owned: renaming it where it is published
* strands every node still reading the old one. Offer to bring them along
* rather than deciding for the user.
*/
const proposeRename = useCallback(
(from: string, to: string) => {
if (!from || !to) return
const message = qualify(flowName, from)
const bound = definitions.filter((node) =>
[...(node.requires ?? []), ...(node.provides ?? [])].some(
(spec) => qualify(flowName, spec.name ?? "") === message,
),
)
if (bound.length) setRenamed({ from, to, count: bound.length })
},
[definitions, flowName],
)
/** Carry a rename to everything bound to the old name, as one edit. */
const applyRename = useCallback(
({ from, to }: MessageRename) => {
const message = qualify(flowName, from)
const follow = (specs: MessageSpec[] | undefined) =>
(specs ?? []).map((spec) =>
qualify(flowName, spec.name ?? "") === message
? { ...spec, name: to, port: "" }
: spec,
)
commit(
definitions.map((node) => ({
...node,
requires: follow(node.requires),
provides: follow(node.provides),
})),
)
setRenamed(null)
},
[commit, definitions, flowName],
)
const focusNode = useCallback( const focusNode = useCallback(
(qualifiedId: string) => { (qualifiedId: string) => {
const id = qualifiedId.startsWith(`${flowName}.`) const id = qualifiedId.startsWith(`${flowName}.`)
@@ -479,7 +657,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
deleteKeyCode={["Backspace", "Delete"]} deleteKeyCode={["Backspace", "Delete"]}
className="h-full w-full" className="h-full w-full"
> >
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} /> <CanvasBackground />
</ReactFlow> </ReactFlow>
{editorExpanded ? null : ( {editorExpanded ? null : (
@@ -487,6 +665,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flows={flows.data} flows={flows.data}
active={flowName} active={flowName}
saving={saving.isPending} saving={saving.isPending}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
/> />
)} )}
@@ -495,10 +677,6 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
issues={issues} issues={issues}
running={runMutation.isPending} running={runMutation.isPending}
onAddNode={() => setPaletteOpen(true)} onAddNode={() => setPaletteOpen(true)}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
onRun={() => { onRun={() => {
flush() flush()
runMutation.mutate() runMutation.mutate()
@@ -547,6 +725,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
expanded={editorExpanded} expanded={editorExpanded}
onToggleExpand={() => setEditorExpanded((wide) => !wide)} onToggleExpand={() => setEditorExpanded((wide) => !wide)}
onChange={updateNode} onChange={updateNode}
onRenameMessage={proposeRename}
onSaveSource={(code) => { onSaveSource={(code) => {
if (selected) sourceMutation.mutate({ nodeId: selected.id, code }) if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
}} }}
@@ -618,6 +797,33 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog
open={Boolean(renamed)}
onOpenChange={(open) => !open && setRenamed(null)}
>
<DialogContent data-testid="rename-message">
<DialogHeader>
<DialogTitle>Rename this message everywhere?</DialogTitle>
<DialogDescription>
{renamed?.count === 1
? "One other node is"
: `${renamed?.count} other nodes are`}{" "}
still bound to <span className="font-mono">{renamed?.from}</span>.
They keep the old name unless they follow it to{" "}
<span className="font-mono">{renamed?.to}</span>.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button variant="ghost" onClick={() => setRenamed(null)}>
Leave them
</Button>
<Button onClick={() => renamed && applyRename(renamed)}>
Rename everywhere
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
) )
} }
@@ -634,14 +840,7 @@ function mergeDragged(
/** Seed xyflow's own node state once; it owns positions while you drag. */ /** Seed xyflow's own node state once; it owns positions while you drag. */
function useUnpositionedNodes(definitions: NodeDef_Input[]) { function useUnpositionedNodes(definitions: NodeDef_Input[]) {
return useNodesState<FlowCanvasNode>( return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
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 }) { export function FlowEditor({ flowName }: { flowName: string }) {
+20 -2
View File
@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router" import { Link, useNavigate } from "@tanstack/react-router"
import { Check, Loader2, Plus, WifiOff } from "lucide-react" import { Check, Loader2, Pencil, Plus, WifiOff } from "lucide-react"
import { motion } from "motion/react" import { motion } from "motion/react"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
@@ -114,10 +114,12 @@ export function FlowTabs({
flows, flows,
active, active,
saving, saving,
onEditFlow,
}: { }: {
flows: FlowSummary[] flows: FlowSummary[]
active: string active: string
saving: boolean saving: boolean
onEditFlow: () => void
}) { }) {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const connected = useLiveConnection() const connected = useLiveConnection()
@@ -129,7 +131,7 @@ export function FlowTabs({
initial="hidden" initial="hidden"
animate="visible" animate="visible"
transition={transitions.emphasized} 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" className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100%-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 md:hidden" /> <SidebarTrigger className="size-8 shrink-0 text-muted-foreground md:hidden" />
@@ -166,6 +168,22 @@ export function FlowTabs({
<TooltipContent>New flow</TooltipContent> <TooltipContent>New flow</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground"> <span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
+1 -1
View File
@@ -72,7 +72,7 @@ function LiveEdgeComponent({
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, 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" className="pointer-events-none absolute max-w-[140px] truncate rounded-full border border-border bg-card px-2 py-0.5 font-mono text-xs text-foreground"
> >
{formatValue(live.value)} {formatValue(live.value)}
</div> </div>
+37 -3
View File
@@ -44,14 +44,20 @@ function MessageNameInput({
placeholder, placeholder,
autoFocus, autoFocus,
onChange, onChange,
onRenamed,
}: { }: {
value: string value: string
suggestions: string[] suggestions: string[]
placeholder: string placeholder: string
autoFocus: boolean autoFocus: boolean
onChange: (next: string) => void onChange: (next: string) => void
/** The name as it was before this edit, once the field is done with. */
onRenamed?: (previous: string, next: string) => void
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Every keystroke commits, so a rename is only a rename once the user is
// finished with the field.
const before = useRef(value)
const matches = suggestions.filter( const matches = suggestions.filter(
(name) => (name) =>
name !== value && name.toLowerCase().includes(value.toLowerCase()), name !== value && name.toLowerCase().includes(value.toLowerCase()),
@@ -68,8 +74,15 @@ function MessageNameInput({
// A port added by hand is meant to be named right away. // A port added by hand is meant to be named right away.
autoFocus={autoFocus} autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm" className="h-8 flex-1 font-mono text-sm"
onFocus={() => setOpen(true)} onFocus={() => {
onBlur={() => setOpen(false)} before.current = value
setOpen(true)
}}
onBlur={() => {
setOpen(false)
if (before.current !== value) onRenamed?.(before.current, value)
before.current = value
}}
onChange={(event) => { onChange={(event) => {
onChange(event.target.value) onChange(event.target.value)
setOpen(true) setOpen(true)
@@ -122,6 +135,7 @@ function PortList({
emptyHint, emptyHint,
suggestions, suggestions,
onChange, onChange,
onRenamed,
}: { }: {
title: string title: string
specs: MessageSpec[] specs: MessageSpec[]
@@ -129,6 +143,7 @@ function PortList({
emptyHint: string emptyHint: string
suggestions: string[] suggestions: string[]
onChange: (next: MessageSpec[]) => void onChange: (next: MessageSpec[]) => void
onRenamed?: (previous: string, next: string) => void
}) { }) {
// The port just added, so its name field can take focus. // The port just added, so its name field can take focus.
const [freshIndex, setFreshIndex] = useState<number | null>(null) const [freshIndex, setFreshIndex] = useState<number | null>(null)
@@ -169,6 +184,7 @@ function PortList({
placeholder={`name in ${flow}`} placeholder={`name in ${flow}`}
autoFocus={index === freshIndex} autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })} onChange={(name) => update(index, { name, port: "" })}
onRenamed={onRenamed}
/> />
<Select <Select
value={spec.dtype ?? "float"} value={spec.dtype ?? "float"}
@@ -212,7 +228,12 @@ function ParamsForm({
}) { }) {
const properties = (schema?.properties ?? {}) as Record< const properties = (schema?.properties ?? {}) as Record<
string, string,
{ type?: string; title?: string; default?: unknown } {
type?: string
title?: string
description?: string
default?: unknown
}
> >
const entries = Object.entries(properties) const entries = Object.entries(properties)
if (entries.length === 0) return null if (entries.length === 0) return null
@@ -265,6 +286,11 @@ function ParamsForm({
) )
} }
/> />
{property.description ? (
<p className="text-xs text-muted-foreground">
{property.description}
</p>
) : null}
</div> </div>
) )
})} })}
@@ -279,6 +305,7 @@ function PanelBody({
suggestions, suggestions,
expanded, expanded,
onChange, onChange,
onRenameMessage,
onSaveSource, onSaveSource,
onToggleExpand, onToggleExpand,
}: { }: {
@@ -288,6 +315,7 @@ function PanelBody({
suggestions: PortSuggestions suggestions: PortSuggestions
expanded: boolean expanded: boolean
onChange: (next: NodeDef_Input) => void onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void onSaveSource: (code: string) => void
onToggleExpand: () => void onToggleExpand: () => void
}) { }) {
@@ -341,6 +369,9 @@ function PanelBody({
emptyHint="Nothing yet. Add a message this node publishes." emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides} suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })} onChange={(provides) => onChange({ ...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}
/> />
<ParamsForm <ParamsForm
schema={nodeType?.params_schema} schema={nodeType?.params_schema}
@@ -398,6 +429,7 @@ export function NodePanel({
suggestions, suggestions,
expanded, expanded,
onChange, onChange,
onRenameMessage,
onSaveSource, onSaveSource,
onToggleExpand, onToggleExpand,
onClose, onClose,
@@ -409,6 +441,7 @@ export function NodePanel({
suggestions: PortSuggestions suggestions: PortSuggestions
expanded: boolean expanded: boolean
onChange: (next: NodeDef_Input) => void onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void onSaveSource: (code: string) => void
onToggleExpand: () => void onToggleExpand: () => void
onClose: () => void onClose: () => void
@@ -455,6 +488,7 @@ export function NodePanel({
suggestions={suggestions} suggestions={suggestions}
expanded={expanded} expanded={expanded}
onChange={onChange} onChange={onChange}
onRenameMessage={onRenameMessage}
onSaveSource={onSaveSource} onSaveSource={onSaveSource}
onToggleExpand={onToggleExpand} onToggleExpand={onToggleExpand}
/> />
+9 -3
View File
@@ -64,6 +64,11 @@ export function useAutosave(name: string): {
}, },
}) })
// react-query hands back a new mutation object on every render, so flushing
// has to hang off `mutate`, which is stable. Depending on the whole mutation
// re-ran the effect below on every render, and its cleanup cancelled the
// pending save before it ever fired.
const { mutate } = mutation
const flush = useCallback(() => { const flush = useCallback(() => {
if (timer.current) { if (timer.current) {
clearTimeout(timer.current) clearTimeout(timer.current)
@@ -72,9 +77,9 @@ export function useAutosave(name: string): {
const definition = pending.current const definition = pending.current
pending.current = null pending.current = null
if (definition) { if (definition) {
mutation.mutate(definition) mutate(definition)
} }
}, [mutation]) }, [mutate])
const save = useCallback( const save = useCallback(
(definition: FlowDef_Input) => { (definition: FlowDef_Input) => {
@@ -93,7 +98,8 @@ export function useAutosave(name: string): {
document.addEventListener("visibilitychange", onHidden) document.addEventListener("visibilitychange", onHidden)
return () => { return () => {
document.removeEventListener("visibilitychange", onHidden) document.removeEventListener("visibilitychange", onHidden)
if (timer.current) clearTimeout(timer.current) // Unmounting is a flow switch, not a reason to drop a queued edit.
flush()
} }
}, [flush]) }, [flush])
+35
View File
@@ -24,6 +24,17 @@ async function setNodeSource(page: Page, nodeId: string, code: string) {
expect(response.ok()).toBeTruthy() expect(response.ok()).toBeTruthy()
} }
/** Read a node's source back, to check what a delete and an undo did to it. */
async function nodeSource(page: Page, nodeId: string) {
const token = await page.evaluate(() => localStorage.getItem("access_token"))
const response = await page.request.get(
`${apiUrl}/api/v1/flows/${flowName}/nodes/${nodeId}/source`,
{ headers: { Authorization: `Bearer ${token}` } },
)
expect(response.ok()).toBeTruthy()
return (await response.json()).code as string
}
async function addFunctionNode(page: Page, expected: number) { async function addFunctionNode(page: Page, expected: number) {
await page.getByTestId("add-node").click() await page.getByTestId("add-node").click()
await page await page
@@ -102,3 +113,27 @@ test("running a flow puts values on its edges", async ({ page }) => {
{ timeout: 15000 }, { timeout: 15000 },
) )
}) })
/**
* The mis-click case: a node's source lives beside the flow document, so undoing
* a delete has to bring the code back with the node, not just the box.
*/
test("undo brings a deleted node back with its source", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
await page.locator(".react-flow__node").first().click()
await page.getByRole("button", { name: "Delete node" }).click()
await expect(page.locator(".react-flow__node")).toHaveCount(1)
// Deleting closes the panel, so the shortcut reaches the canvas rather than
// a field, which keeps its own undo.
await page.keyboard.press("ControlOrMeta+z")
await expect(page.locator(".react-flow__node")).toHaveCount(2)
// Let the autosave land, then come back fresh and read the source.
await page.waitForTimeout(1500)
await page.reload()
await expect(page.locator(".react-flow__node")).toHaveCount(2)
expect(await nodeSource(page, "python")).toContain("42.0")
})