Find a flow's declared inputs and outputs in the global search

/search lists each input and batch output under its qualified message,
and picking one opens the flow on the label's id, which the editor now
treats like a click on the label: it opens the flow's settings.
This commit is contained in:
2026-10-05 18:15:35 +02:00
parent 901e85f3fb
commit a677409079
9 changed files with 110 additions and 21 deletions
@@ -6,6 +6,8 @@ import {
KeyRound,
LayoutDashboard,
LayoutGrid,
LogIn,
LogOut,
type LucideIcon,
MonitorSmartphone,
Package,
@@ -45,6 +47,8 @@ const GROUPS: {
}[] = [
{ category: "flow", label: "Flows", icon: Workflow },
{ category: "node", label: "Nodes", icon: Box },
{ category: "input", label: "Inputs", icon: LogIn },
{ category: "output", label: "Outputs", icon: LogOut },
{ category: "dashboard", label: "Dashboards", icon: LayoutDashboard },
{ category: "widget", label: "Widgets", icon: LayoutGrid },
{ category: "panel", label: "Panels", icon: MonitorSmartphone },
@@ -106,7 +110,7 @@ function couldMatch(value: string, query: string): boolean {
* narrow as they are typed without a round trip per keystroke.
*
* cmdk does the matching over what is mounted, so the entries are bucketed by
* category once per fetch rather than filtered nine times per keystroke, and
* category once per fetch rather than filtered eleven times per keystroke, and
* each group is capped — a list nobody can scroll to the end of is not a
* better answer than its first twenty.
*/
@@ -129,7 +133,7 @@ export function GlobalSearch({
const entries = data ?? []
const typed = query.trim()
const typing = typed.length > 0
// One pass over the index rather than one per group — nine passes over
// One pass over the index rather than one per group — eleven passes over
// every entry on each keystroke. Not memoised: the component returns early
// while closed, so a hook cannot go here, and one pass is already the win.
// The same pass drops what the query cannot reach, so each bucket is already
@@ -157,6 +161,14 @@ export function GlobalSearch({
params: { flowName: entry.parent ?? "" },
search: { node: entry.name },
})
// Arriving on the label's id does what clicking the label does.
case "input":
case "output":
return navigate({
to: "/flows/$flowName",
params: { flowName: entry.parent ?? "" },
search: { node: `${entry.category}:${entry.name}` },
})
case "dashboard":
return navigate({
to: "/dashboards/$name",
+17 -6
View File
@@ -240,7 +240,7 @@ function FlowEditorInner({
onReload,
}: {
flowName: string
/** A node to arrive on, from the address bar. */
/** A node or endpoint to arrive on, from the address bar. */
focus?: string
onReload: () => void
}) {
@@ -272,7 +272,10 @@ function FlowEditorInner({
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
detail.definition.nodes ?? [],
)
const [selectedId, setSelectedId] = useState<string | null>(focus ?? null)
// An endpoint is never selected; arriving on one opens it instead (below).
const [selectedId, setSelectedId] = useState<string | null>(
focus && !isEndpointNode({ id: focus }) ? focus : null,
)
const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
// The edges are derived, so xyflow's own selection would be thrown away on
@@ -642,10 +645,18 @@ function FlowEditorInner({
// The canvas is not remounted per node — that would throw the session away
// on every click in the brain graph — so arriving at a flow already open
// only changes the address. Seeded above for the first arrival, set here for
// the ones after it.
// the ones after it. An endpoint — an input or output found by the search —
// does what clicking its label does, which also covers one the canvas does
// not draw because a dashboard already accounts for it.
useEffect(() => {
if (focus) setSelectedId(focus)
}, [focus])
if (!focus) return
if (isEndpointNode({ id: focus })) {
openEndpoint(focus)
} else {
setFlowPanelOpen(false)
setSelectedId(focus)
}
}, [focus, openEndpoint])
const selected = definitions.find((node) => node.id === selectedId) ?? null
// A panel is the view you are working in, so it takes the room — but never
@@ -1499,7 +1510,7 @@ export function FlowEditor({
focus,
}: {
flowName: string
/** A node to select on arrival — see `FlowEditorInner`. */
/** A node or endpoint to arrive on — see `FlowEditorInner`. */
focus?: string
}) {
const navigate = useNavigate()