"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { useCanvasStore } from "@/store/canvas"; import { statusDotClass } from "@/lib/design-tokens"; export function SearchDialog() { const open = useCanvasStore((s) => s.searchOpen); const setOpen = useCanvasStore((s) => s.setSearchOpen); const [query, setQuery] = useState(""); const [focusedIndex, setFocusedIndex] = useState(-1); const inputRef = useRef(null); const nodes = useCanvasStore((s) => s.nodes); const selectNode = useCanvasStore((s) => s.selectNode); const setPanelTab = useCanvasStore((s) => s.setPanelTab); // Cmd+K to open useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setOpen(true); setQuery(""); } if (e.key === "Escape" && open) { setOpen(false); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [open, setOpen]); useEffect(() => { if (open) { requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); const filtered = nodes.filter((n) => { if (!query) return true; const q = query.toLowerCase(); return ( n.data.name.toLowerCase().includes(q) || (n.data.role || "").toLowerCase().includes(q) || n.data.status.toLowerCase().includes(q) ); }); // Auto-highlight the first match while the user is typing, so Enter // selects something instead of being a no-op. With an empty query we // keep -1 so opening the dialog (which shows ALL workspaces) doesn't // visually pin one row arbitrarily — only commit a highlight once the // user has narrowed the list. useEffect(() => { setFocusedIndex(query && filtered.length > 0 ? 0 : -1); // Re-running on filtered.length keeps the highlight pinned to the // first row while the result set shrinks/grows; the effect handler // above already short-circuits to -1 when results disappear. }, [query, filtered.length]); const handleSelect = useCallback( (nodeId: string) => { selectNode(nodeId); setPanelTab("details"); setOpen(false); }, [selectNode, setPanelTab, setOpen] ); const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setFocusedIndex((i) => Math.min(i + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setFocusedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter" && focusedIndex >= 0 && filtered[focusedIndex]) { e.preventDefault(); handleSelect(filtered[focusedIndex].id); } }, [filtered, focusedIndex, handleSelect] ); const activeDescendant = focusedIndex >= 0 && filtered[focusedIndex] ? `search-result-${filtered[focusedIndex].id}` : undefined; if (!open) return null; return (
setOpen(false)} >
e.stopPropagation()} > {/* Search input */}
0} aria-autocomplete="list" aria-controls="search-results-list" aria-activedescendant={activeDescendant} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={handleInputKeyDown} placeholder="Search workspaces..." className="flex-1 bg-transparent text-sm text-ink placeholder-ink-soft focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded" /> ESC
{/* Results */}
{filtered.length === 0 ? (
{query ? "No workspaces match" : "No workspaces yet"}
) : ( filtered.map((node, index) => ( )) )}
{/* Footer */}
{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}
↑↓ navigate ↵ select
); }