Copy nodes, bind the keys, and keep publish within reach
The floating bars used to make way for a side panel, which hid Publish exactly when a node had just been edited. They keep their lane now and the panel is inset out of it; the enlarged code editor stays a floating surface between the two bars rather than covering them. Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K, copy, paste, and ⌘S — which publishes the flow, or applies the code when the editor has focus. Copying carries a node's own source through localStorage, so a paste works in another flow too. A flow is now named where a node is, at the top of its panel and only there, and both take effect on Confirm. Flow-level edits go through the undo stack with everything else, clicking the canvas puts the flow panel away, a failing node opens the logs filtered to its own traceback, and the panel carries its test id on a phone as well. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -19,7 +19,6 @@ import {
|
||||
} from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Workflow } from "lucide-react"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import {
|
||||
@@ -39,6 +38,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CommandPalette } from "./CommandPalette"
|
||||
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||
@@ -94,8 +95,8 @@ type MessageRename = {
|
||||
|
||||
/** Documents to step back and forward through, newest last. */
|
||||
type History = {
|
||||
past: NodeDef_Input[][]
|
||||
future: NodeDef_Input[][]
|
||||
past: FlowDef_Input[]
|
||||
future: FlowDef_Input[]
|
||||
/** When the last entry was recorded, so a burst of typing stays one edit. */
|
||||
at: number
|
||||
}
|
||||
@@ -103,6 +104,16 @@ type History = {
|
||||
const HISTORY_LIMIT = 50
|
||||
const TYPING_WINDOW = 500
|
||||
|
||||
/** Nodes copied here, and the code of the ones carrying their own. */
|
||||
type NodeClipboard = {
|
||||
nodes: NodeDef_Input[]
|
||||
sources: Record<string, string>
|
||||
}
|
||||
|
||||
// ponytail: localStorage, so a copy survives a flow switch or a second tab but
|
||||
// not a second browser. The system clipboard if that ever matters.
|
||||
const CLIPBOARD_KEY = "fluksio.nodeClipboard"
|
||||
|
||||
/**
|
||||
* Remember the document as it was before a change.
|
||||
*
|
||||
@@ -112,13 +123,15 @@ const TYPING_WINDOW = 500
|
||||
*/
|
||||
function record(
|
||||
history: History,
|
||||
previous: NodeDef_Input[],
|
||||
next: NodeDef_Input[],
|
||||
previous: FlowDef_Input,
|
||||
next: FlowDef_Input,
|
||||
settled: boolean,
|
||||
) {
|
||||
const before = previous.nodes ?? []
|
||||
const after = next.nodes ?? []
|
||||
const sameNodes =
|
||||
previous.length === next.length &&
|
||||
previous.every((node, index) => node.id === next[index].id)
|
||||
before.length === after.length &&
|
||||
before.every((node, index) => node.id === after[index].id)
|
||||
const stillTyping =
|
||||
!settled && sameNodes && Date.now() - history.at < TYPING_WINDOW
|
||||
|
||||
@@ -131,15 +144,6 @@ function record(
|
||||
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,
|
||||
@@ -225,9 +229,10 @@ function FlowEditorInner({
|
||||
const publish = usePublish(flowName)
|
||||
const discard = useDiscardDraft(flowName)
|
||||
|
||||
const [definitions, setDefinitions] = useState<NodeDef_Input[]>(
|
||||
() => detail.definition.nodes ?? [],
|
||||
)
|
||||
// The whole working document, so a flow-level edit is an edit like any
|
||||
// other — undoable, and on screen before the server has seen it.
|
||||
const [flowDoc, setFlowDoc] = useState<FlowDef_Input>(() => detail.definition)
|
||||
const definitions = useMemo(() => flowDoc.nodes ?? [], [flowDoc])
|
||||
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
|
||||
detail.definition.nodes ?? [],
|
||||
)
|
||||
@@ -237,8 +242,12 @@ function FlowEditorInner({
|
||||
const [rebind, setRebind] = useState<Rebind | null>(null)
|
||||
const [renamed, setRenamed] = useState<MessageRename | null>(null)
|
||||
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
|
||||
// The editor at full size covers the canvas, so its chrome steps aside.
|
||||
// The editor at full size takes the width the canvas chrome does not need.
|
||||
const [editorExpanded, setEditorExpanded] = useState(false)
|
||||
// The dock hosts the logs, but a failing node opens them too, at its own
|
||||
// lines — so which node and whether it is open belong here.
|
||||
const [logsOpen, setLogsOpen] = useState(false)
|
||||
const [logsNode, setLogsNode] = useState<string | null>(null)
|
||||
|
||||
const issues = detail.issues ?? []
|
||||
const paused = useFlowPaused(flowName)
|
||||
@@ -247,33 +256,35 @@ function FlowEditorInner({
|
||||
const latest = useRef<FlowDef_Input>(detail.definition)
|
||||
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(
|
||||
/** Put a document on the canvas and on its way to the server. */
|
||||
const applyDoc = useCallback(
|
||||
(next: FlowDef_Input) => {
|
||||
setFlowDoc(next)
|
||||
latest.current = next
|
||||
save(next)
|
||||
},
|
||||
[save],
|
||||
)
|
||||
|
||||
/** Apply a change to the whole document and make it undoable. */
|
||||
const commitDoc = useCallback(
|
||||
(next: FlowDef_Input, settled = false) => {
|
||||
record(history.current, latest.current, next, settled)
|
||||
applyDoc(next)
|
||||
},
|
||||
[applyDoc],
|
||||
)
|
||||
|
||||
/** The same, for the changes that only touch the nodes. */
|
||||
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)
|
||||
commitDoc({ ...latest.current, nodes: placed }, Boolean(positions))
|
||||
},
|
||||
[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],
|
||||
[canvasNodes, commitDoc],
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -285,35 +296,32 @@ function FlowEditorInner({
|
||||
const { past, future } = history.current
|
||||
const remembered = (back ? past : future).pop()
|
||||
if (!remembered) return
|
||||
;(back ? future : past).push(latest.current.nodes ?? [])
|
||||
;(back ? future : past).push(latest.current)
|
||||
history.current.at = 0
|
||||
// xyflow owns the positions, so hand it the remembered ones too.
|
||||
const canvas = toCanvasNodes(remembered)
|
||||
setCanvasNodes(canvas)
|
||||
apply(remembered, canvas)
|
||||
setCanvasNodes(toCanvasNodes(remembered.nodes ?? []))
|
||||
applyDoc(remembered)
|
||||
setInspected(null)
|
||||
},
|
||||
[apply, setCanvasNodes],
|
||||
[applyDoc, 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(
|
||||
() => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])),
|
||||
[nodeTypeInfo],
|
||||
)
|
||||
|
||||
// Which types bring code of their own, so a copy has something to carry.
|
||||
const sourceTypes = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(nodeTypeInfo ?? [])
|
||||
.filter((info) => info.has_source)
|
||||
.map((info) => info.type),
|
||||
),
|
||||
[nodeTypeInfo],
|
||||
)
|
||||
|
||||
// Which types came from an installed connector rather than the engine. They
|
||||
// cannot be in the icon map, so they share one.
|
||||
const pluginTypes = useMemo(
|
||||
@@ -337,6 +345,11 @@ function FlowEditorInner({
|
||||
return map
|
||||
}, [issues])
|
||||
|
||||
const showLogs = useCallback((nodeId: string) => {
|
||||
setLogsNode(nodeId)
|
||||
setLogsOpen(true)
|
||||
}, [])
|
||||
|
||||
// Canvas nodes carry the definition so the node component can render it.
|
||||
const renderedNodes = useMemo(
|
||||
() =>
|
||||
@@ -353,6 +366,7 @@ function FlowEditorInner({
|
||||
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
|
||||
isPlugin: pluginTypes.has(definition?.type ?? ""),
|
||||
issueText: nodeIssues.join("\n"),
|
||||
onShowLogs: showLogs,
|
||||
} satisfies FlowNodeData,
|
||||
}
|
||||
}),
|
||||
@@ -363,6 +377,7 @@ function FlowEditorInner({
|
||||
issuesByNode,
|
||||
pluginTypes,
|
||||
selectedId,
|
||||
showLogs,
|
||||
typeLabels,
|
||||
],
|
||||
)
|
||||
@@ -492,25 +507,6 @@ function FlowEditorInner({
|
||||
onError: () => showErrorToast("The flow could not be paused."),
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (newName: string) =>
|
||||
FlowsService.renameFlow({
|
||||
name: flowName,
|
||||
requestBody: { new_name: newName },
|
||||
}),
|
||||
onSuccess: (detail) => {
|
||||
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
||||
setFlowPanelOpen(false)
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName: detail.definition.name },
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
onError: () =>
|
||||
showErrorToast("That name is taken, or is not a valid flow name."),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => FlowsService.deleteFlow({ name: flowName }),
|
||||
onSuccess: () => {
|
||||
@@ -731,10 +727,121 @@ function FlowEditorInner({
|
||||
[fitView, flowName],
|
||||
)
|
||||
|
||||
/** Put the stored draft live. Publishing what is queued means saving first. */
|
||||
const publishFlow = useCallback(async () => {
|
||||
await flush()
|
||||
// Publish what was actually stored: the version only advances once the
|
||||
// queued save has landed.
|
||||
const current = queryClient.getQueryData<FlowDetail>(
|
||||
flowKeys.detail(flowName),
|
||||
)
|
||||
publish.mutate(current?.definition.version ?? 1)
|
||||
}, [flowName, flush, publish.mutate, queryClient])
|
||||
|
||||
/** Copy the selected nodes, with the code of the ones carrying their own. */
|
||||
const copyNodes = useCallback(async () => {
|
||||
const ids = new Set(
|
||||
canvasNodes.filter((node) => node.selected).map((node) => node.id),
|
||||
)
|
||||
if (selectedId) ids.add(selectedId)
|
||||
const picked = definitions.filter((node) => ids.has(node.id))
|
||||
if (!picked.length) return
|
||||
|
||||
const sources: Record<string, string> = {}
|
||||
await Promise.all(
|
||||
picked
|
||||
// A shared node already points at the library copy; only a private
|
||||
// one has code that has to travel with it.
|
||||
.filter((node) => !node.source_ref && sourceTypes.has(node.type ?? ""))
|
||||
.map(async (node) => {
|
||||
const { code } = await FlowsService.readNodeSource({
|
||||
name: flowName,
|
||||
nodeId: node.id,
|
||||
})
|
||||
sources[node.id] = code
|
||||
}),
|
||||
).catch(() => undefined)
|
||||
|
||||
localStorage.setItem(
|
||||
CLIPBOARD_KEY,
|
||||
JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard),
|
||||
)
|
||||
}, [canvasNodes, definitions, flowName, selectedId, sourceTypes])
|
||||
|
||||
/**
|
||||
* Paste them here, renamed around whatever this flow already holds. The
|
||||
* document only names a node's code, so the copy is written per node once
|
||||
* the definition is on its way.
|
||||
*/
|
||||
const pasteNodes = useCallback(() => {
|
||||
const stored = localStorage.getItem(CLIPBOARD_KEY)
|
||||
if (!stored) return
|
||||
let clipboard: NodeClipboard
|
||||
try {
|
||||
clipboard = JSON.parse(stored)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let pool = definitions
|
||||
const pasted: NodeDef_Input[] = []
|
||||
for (const node of clipboard.nodes ?? []) {
|
||||
const position = freePosition(pool, {
|
||||
// Offset, so a copy of a node in this flow is visibly its own.
|
||||
x: (node.position?.x ?? 0) + 48,
|
||||
y: (node.position?.y ?? 0) + 48,
|
||||
})
|
||||
const copy = { ...node, id: uniqueNodeId(pool, node.id), position }
|
||||
pool = [...pool, copy]
|
||||
pasted.push(copy)
|
||||
}
|
||||
if (!pasted.length) return
|
||||
|
||||
const nextCanvas = [
|
||||
...canvasNodes,
|
||||
...pasted.map(
|
||||
(node) =>
|
||||
({
|
||||
id: node.id,
|
||||
type: "flow",
|
||||
position: node.position,
|
||||
data: {},
|
||||
}) as FlowCanvasNode,
|
||||
),
|
||||
]
|
||||
setCanvasNodes(nextCanvas)
|
||||
commit(pool, nextCanvas)
|
||||
setSelectedId(pasted[pasted.length - 1].id)
|
||||
|
||||
clipboard.nodes.forEach((node, index) => {
|
||||
const code = clipboard.sources?.[node.id]
|
||||
if (code !== undefined) {
|
||||
sourceMutation.mutate({ nodeId: pasted[index].id, code })
|
||||
}
|
||||
})
|
||||
}, [canvasNodes, commit, definitions, setCanvasNodes, sourceMutation.mutate])
|
||||
|
||||
useShortcuts(
|
||||
{
|
||||
"mod+z": () => step(true),
|
||||
"mod+shift+z": () => step(false),
|
||||
"mod+c": () => void copyNodes(),
|
||||
"mod+v": pasteNodes,
|
||||
"mod+k": () => setPaletteOpen((open) => !open),
|
||||
// Inside the code editor ⌘S applies that code, which the node panel
|
||||
// owns; anywhere else on the canvas it puts the flow live.
|
||||
"mod+s": (event) => {
|
||||
if (!inCodeEditor(event.target)) void publishFlow()
|
||||
},
|
||||
},
|
||||
// Both stay reachable while typing: one is the editor's own save, the
|
||||
// other is how you reach anything at all.
|
||||
["mod+s", "mod+k"],
|
||||
)
|
||||
|
||||
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
||||
// A panel is the view you are working in: the bars would only compete with
|
||||
// it, so they step aside until it closes. On a phone the panel covers them
|
||||
// anyway, and its own close button is the way back.
|
||||
// A panel is the view you are working in, so it takes the room — but never
|
||||
// the lanes the bars sit in: publishing is most wanted right after editing.
|
||||
const panelOpen = Boolean(selected) || flowPanelOpen
|
||||
|
||||
return (
|
||||
@@ -776,7 +883,10 @@ function FlowEditorInner({
|
||||
setSelectedId(node.id)
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
// Clicking the canvas is how you put a panel away, whichever one it
|
||||
// is: the graph is what you went back to look at.
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(false)
|
||||
setEditorExpanded(false)
|
||||
setInspected(null)
|
||||
}}
|
||||
@@ -813,50 +923,55 @@ function FlowEditorInner({
|
||||
<CanvasBackground />
|
||||
</ReactFlow>
|
||||
|
||||
<AnimatePresence>
|
||||
{panelOpen ? null : (
|
||||
<FlowTabs
|
||||
key="flow-tabs"
|
||||
flows={flows.data}
|
||||
active={flowName}
|
||||
saving={saving.isPending}
|
||||
hasDraft={detail.has_draft ?? false}
|
||||
publishing={publish.isPending || saving.isPending}
|
||||
onPublish={async () => {
|
||||
// Publish what was actually stored: the version only advances
|
||||
// once the queued save has landed.
|
||||
await flush()
|
||||
const current = queryClient.getQueryData<FlowDetail>(
|
||||
flowKeys.detail(flowName),
|
||||
)
|
||||
publish.mutate(current?.definition.version ?? 1)
|
||||
}}
|
||||
onEditFlow={() => {
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(true)
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
* The bars keep their own lane rather than making way for a panel: they
|
||||
* carry Publish, which is what you reach for the moment a node is done.
|
||||
* The floating panel is inset out of that lane instead.
|
||||
*/}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 transition-[right] duration-200",
|
||||
panelOpen && !editorExpanded && "md:right-[27rem]",
|
||||
)}
|
||||
>
|
||||
<FlowTabs
|
||||
flows={flows.data}
|
||||
active={flowName}
|
||||
saving={saving.isPending}
|
||||
hasDraft={detail.has_draft ?? false}
|
||||
publishing={publish.isPending || saving.isPending}
|
||||
onPublish={() => void publishFlow()}
|
||||
onEditFlow={() => {
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
{panelOpen ? null : (
|
||||
<FlowDock
|
||||
key="flow-dock"
|
||||
flow={flowName}
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
enabled={detail.enabled ?? true}
|
||||
paused={paused}
|
||||
onAddNode={() => setPaletteOpen(true)}
|
||||
onRun={async () => {
|
||||
// Running executes what is stored, so the queued edit goes first.
|
||||
await flush()
|
||||
runMutation.mutate()
|
||||
}}
|
||||
onTogglePause={() => pauseMutation.mutate(!paused)}
|
||||
onFocusNode={focusNode}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<FlowDock
|
||||
flow={flowName}
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
enabled={detail.enabled ?? true}
|
||||
paused={paused}
|
||||
logs={{
|
||||
open: logsOpen,
|
||||
node: logsNode,
|
||||
onOpenChange: (open) => {
|
||||
setLogsOpen(open)
|
||||
if (!open) setLogsNode(null)
|
||||
},
|
||||
onClearNode: () => setLogsNode(null),
|
||||
}}
|
||||
onAddNode={() => setPaletteOpen(true)}
|
||||
onRun={async () => {
|
||||
// Running executes what is stored, so the queued edit goes first.
|
||||
await flush()
|
||||
runMutation.mutate()
|
||||
}}
|
||||
onTogglePause={() => pauseMutation.mutate(!paused)}
|
||||
onFocusNode={focusNode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{definitions.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
@@ -875,17 +990,10 @@ function FlowEditorInner({
|
||||
|
||||
<FlowPanel
|
||||
open={flowPanelOpen && !selected}
|
||||
definition={{ ...detail.definition, nodes: definitions }}
|
||||
definition={flowDoc}
|
||||
nodeCount={definitions.length}
|
||||
renaming={renameMutation.isPending}
|
||||
onChange={(next) => {
|
||||
flush()
|
||||
save({ ...next, nodes: definitions })
|
||||
}}
|
||||
onRename={async (newName) => {
|
||||
await flush()
|
||||
renameMutation.mutate(newName)
|
||||
}}
|
||||
// Confirmed, so it is a finished edit rather than a run of keystrokes.
|
||||
onChange={(next) => commitDoc({ ...next, nodes: definitions }, true)}
|
||||
onDelete={() => deleteMutation.mutate()}
|
||||
enabled={detail.enabled ?? true}
|
||||
toggling={enableMutation.isPending}
|
||||
|
||||
Reference in New Issue
Block a user