feat(app): ⌘K command palette — app-wide launcher

The keyboard twin of the map's inspect panel: ⌘K (or the "Launch…" nav button)
from any page. Empty query = the "Start Menu" (browsable launchable apps, local +
remote, grouped by machine badge); typing searches every deployment. Enter
launches the app in a new tab; per-result actions jump to it on the map
(/map?focus=<id>, which the map now honors by selecting + centering) or open its
Castle details. Navigation/launch only — no mutation — for v1.
This commit is contained in:
2026-07-06 20:07:50 -07:00
parent 778772d863
commit c1ec216ea4
3 changed files with 237 additions and 1 deletions

View File

@@ -16,7 +16,7 @@ import {
type ReactFlowInstance,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useNavigate } from "react-router-dom"
import { useNavigate, useSearchParams } from "react-router-dom"
import {
BoxSelect,
ExternalLink,
@@ -429,6 +429,7 @@ export function SystemMapPage() {
const [addExt, setAddExt] = useState(false)
const openProgram = useCallback((program: string) => navigate(`/programs/${program}`), [navigate])
const [searchParams] = useSearchParams()
// Kind lookup for handlers (which config section to write). Kept in a ref so the
// stable callbacks below don't need it in their dep arrays.
@@ -955,6 +956,17 @@ export function SystemMapPage() {
setFocus(picks.length === 1 ? picks[0].id : null)
}, [])
// "Go to on map" from the command palette (/map?focus=<id>): select + center it.
useEffect(() => {
const f = searchParams.get("focus")
if (!f) return
const t = setTimeout(() => {
setFocus(f)
rfRef.current?.fitView({ nodes: [{ id: f }], padding: 0.6, duration: 400 })
}, 200)
return () => clearTimeout(t)
}, [searchParams])
// Remember where the user dropped a node (persist across refetches + reloads).
const onNodeDragStop = useCallback((_e: MouseEvent | TouchEvent, _n: Node, dragged: Node[]) => {
for (const nd of dragged) posRef.current![nd.id] = nd.position