diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx index 496ee25..80ec3d8 100644 --- a/app/src/components/CommandPalette.tsx +++ b/app/src/components/CommandPalette.tsx @@ -1,11 +1,15 @@ import { useEffect, useMemo, useState } from "react" import { useNavigate } from "react-router-dom" -import { ExternalLink, Map as MapIcon, Maximize2, Search } from "lucide-react" -import { useGateway, useGraph, useMeshDeployments } from "@/services/api/hooks" +import { ExternalLink, Gauge, Maximize2, Package, Search } from "lucide-react" +import { useGateway, useGraph, useMeshDeployments, usePrograms } from "@/services/api/hooks" +import { kindIcon } from "@/lib/labels" -// The command palette โ€” the keyboard twin of the map's inspect panel. โŒ˜K from -// anywhere: an empty query is the "Start Menu" (launchable apps); typing searches -// every deployment. Enter launches (or, for non-launchable, jumps to it on the map). +// The command palette โ€” the keyboard twin of the System map's inspect panel. โŒ˜K +// from anywhere: an empty query is the "Start Menu" (launchable apps); typing +// searches every deployment and program. One row per real thing: a deployed +// program is a single row (with a ๐Ÿ“ฆ jump to its source page); a source-only +// program โ€” one with no deployment โ€” gets its own row. Enter launches (or, for +// non-launchable, jumps to it in the System map, else opens its detail page). interface AppItem { id: string name: string @@ -13,7 +17,8 @@ interface AppItem { machine: string | null // remote hostname, or null for local launchUrl?: string detailPath: string - mapNodeId: string + mapNodeId?: string // set for graph/mesh nodes; absent for catalog-only programs + programPath?: string // the source/catalog page behind a deployment (/programs/) } function detailPathFor(kind: string, name: string): string { @@ -51,12 +56,18 @@ function PaletteBody({ onClose }: { onClose: () => void }) { const { data: graph } = useGraph() const { data: gateway } = useGateway() const { data: mesh } = useMeshDeployments() + const { data: programs } = usePrograms() const [query, setQuery] = useState("") const [sel, setSel] = useState(0) const apps = useMemo(() => { const domain = gateway?.domain const out: AppItem[] = [] + // Programs already represented by a local deployment row โ€” so we don't list + // them a second time as standalone catalog entries. + const deployedPrograms = new Set( + (graph?.nodes ?? []).map((n) => n.program).filter((p): p is string => !!p), + ) for (const n of graph?.nodes ?? []) { const launchUrl = n.kind === "reference" @@ -75,6 +86,7 @@ function PaletteBody({ onClose }: { onClose: () => void }) { launchUrl, detailPath: detailPathFor(n.kind, n.name), mapNodeId: n.name, + programPath: n.program ? `/programs/${n.program}` : undefined, }) } for (const md of mesh?.deployments ?? []) { @@ -96,8 +108,22 @@ function PaletteBody({ onClose }: { onClose: () => void }) { mapNodeId: `__remote_${md.node}_${md.name}__`, }) } + // Source-only programs โ€” those with no deployment on this node. A deployed + // program is already reachable via its deployment row's ๐Ÿ“ฆ icon, so it isn't + // repeated here. Not launchable, so these surface only when the user types + // (never in the empty "Start Menu"); Enter opens the program detail page. + for (const p of programs ?? []) { + if (deployedPrograms.has(p.id)) continue + out.push({ + id: `program:${p.id}`, + name: p.id, + kind: "program", + machine: null, + detailPath: `/programs/${p.id}`, + }) + } return out - }, [graph, gateway, mesh]) + }, [graph, gateway, mesh, programs]) const results = useMemo(() => { const q = query.trim().toLowerCase() @@ -109,9 +135,11 @@ function PaletteBody({ onClose }: { onClose: () => void }) { const launch = (a: AppItem) => { onClose() if (a.launchUrl) window.open(a.launchUrl, "_blank", "noreferrer") - else navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`) + else if (a.mapNodeId) navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`) + else navigate(a.detailPath) } const goToMap = (a: AppItem) => { + if (!a.mapNodeId) return onClose() navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`) } @@ -119,6 +147,11 @@ function PaletteBody({ onClose }: { onClose: () => void }) { onClose() navigate(a.detailPath) } + const goToProgram = (a: AppItem) => { + if (!a.programPath) return + onClose() + navigate(a.programPath) + } return (
@@ -159,13 +192,16 @@ function PaletteBody({ onClose }: { onClose: () => void }) { {results.length === 0 && (
No matches.
)} - {results.map((a, i) => ( + {results.map((a, i) => { + const KindIcon = kindIcon(a.kind) + return (
setSel(i)} onClick={() => launch(a)} className={`flex cursor-pointer items-center gap-2 px-3 py-2 text-sm ${i === sel ? "bg-white/10" : ""}`} > + {a.name} {a.machine && ( {a.machine} @@ -182,13 +218,24 @@ function PaletteBody({ onClose }: { onClose: () => void }) { )} - + {a.mapNodeId && ( + + )} + {a.programPath && ( + + )}
- ))} + ) + })}
โ†‘โ†“ navigate ยท โ†ต launch/open ยท esc close