From 4f6214a5cec5472e427a470ec2925a6de8a01dd7 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 14 Mar 2026 19:09:57 +0800 Subject: [PATCH] feat(dashboard): wire core SearchEngine with fuzzy matching and scored highlighting Replace manual substring search in Zustand store with core SearchEngine (Fuse.js fuzzy matching). SearchBar now shows a top-5 dropdown with type badges and relevance bars; closes on Escape/outside click. GraphView updated to work with SearchResult[] (nodeId+score) instead of string[]. Added sub-path exports to core package.json so the dashboard can import search/types modules without pulling in Node.js-only dependencies. Co-Authored-By: Claude Opus 4.6 --- packages/core/package.json | 14 ++ .../dashboard/src/components/GraphView.tsx | 2 +- .../dashboard/src/components/SearchBar.tsx | 163 +++++++++++++++--- packages/dashboard/src/store.ts | 31 ++-- 4 files changed, 170 insertions(+), 40 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index fd2d932..e8a0555 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,6 +4,20 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./search": { + "types": "./dist/search.d.ts", + "default": "./dist/search.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + } + }, "scripts": { "build": "tsc", "test": "vitest run" diff --git a/packages/dashboard/src/components/GraphView.tsx b/packages/dashboard/src/components/GraphView.tsx index d544ac7..cfd6299 100644 --- a/packages/dashboard/src/components/GraphView.tsx +++ b/packages/dashboard/src/components/GraphView.tsx @@ -35,7 +35,7 @@ export default function GraphView() { nodeType: node.type, summary: node.summary, complexity: node.complexity, - isHighlighted: searchResults.includes(node.id), + isHighlighted: searchResults.some((r) => r.nodeId === node.id), isSelected: selectedNodeId === node.id, }, })); diff --git a/packages/dashboard/src/components/SearchBar.tsx b/packages/dashboard/src/components/SearchBar.tsx index 362b520..612de6b 100644 --- a/packages/dashboard/src/components/SearchBar.tsx +++ b/packages/dashboard/src/components/SearchBar.tsx @@ -1,36 +1,151 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import { useDashboardStore } from "../store"; +const typeBadgeColors: Record = { + file: "bg-blue-700 text-blue-200", + function: "bg-green-700 text-green-200", + class: "bg-purple-700 text-purple-200", + module: "bg-orange-700 text-orange-200", + concept: "bg-pink-700 text-pink-200", +}; + export default function SearchBar() { const searchQuery = useDashboardStore((s) => s.searchQuery); const searchResults = useDashboardStore((s) => s.searchResults); + const graph = useDashboardStore((s) => s.graph); const setSearchQuery = useDashboardStore((s) => s.setSearchQuery); + const selectNode = useDashboardStore((s) => s.selectNode); + + const [dropdownOpen, setDropdownOpen] = useState(false); + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Build a lookup map for node details + const nodeMap = new Map( + (graph?.nodes ?? []).map((n) => [n.id, n]), + ); + + const topResults = searchResults.slice(0, 5); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setDropdownOpen(true); + }, + [setSearchQuery], + ); + + const handleResultClick = useCallback( + (nodeId: string) => { + selectNode(nodeId); + setDropdownOpen(false); + }, + [selectNode], + ); + + // Close dropdown on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setDropdownOpen(false); + inputRef.current?.blur(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const showDropdown = dropdownOpen && searchQuery.trim() && topResults.length > 0; return ( -
- - +
+ + + + setDropdownOpen(true)} + placeholder="Search nodes by name, summary, or tags..." + className="flex-1 bg-gray-700 text-white text-sm rounded px-3 py-1.5 border border-gray-600 focus:outline-none focus:border-blue-500 placeholder-gray-400" /> - - setSearchQuery(e.target.value)} - placeholder="Search nodes by name, summary, or tags..." - className="flex-1 bg-gray-700 text-white text-sm rounded px-3 py-1.5 border border-gray-600 focus:outline-none focus:border-blue-500 placeholder-gray-400" - /> - {searchQuery.trim() && ( - - {searchResults.length} result{searchResults.length !== 1 ? "s" : ""} - + {searchQuery.trim() && ( + + {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}{" "} + (fuzzy) + + )} +
+ + {/* Dropdown results */} + {showDropdown && ( +
+ {topResults.map((result) => { + const node = nodeMap.get(result.nodeId); + if (!node) return null; + + const relevance = Math.round((1 - result.score) * 100); + const badgeColor = typeBadgeColors[node.type] ?? typeBadgeColors.file; + + return ( + + ); + })} +
)}
); diff --git a/packages/dashboard/src/store.ts b/packages/dashboard/src/store.ts index bbf00cf..37b8904 100644 --- a/packages/dashboard/src/store.ts +++ b/packages/dashboard/src/store.ts @@ -1,11 +1,14 @@ import { create } from "zustand"; -import type { KnowledgeGraph } from "@understand-anything/core"; +import { SearchEngine } from "@understand-anything/core/search"; +import type { SearchResult } from "@understand-anything/core/search"; +import type { KnowledgeGraph } from "@understand-anything/core/types"; interface DashboardStore { graph: KnowledgeGraph | null; selectedNodeId: string | null; searchQuery: string; - searchResults: string[]; // node IDs + searchResults: SearchResult[]; + searchEngine: SearchEngine | null; setGraph: (graph: KnowledgeGraph) => void; selectNode: (nodeId: string | null) => void; @@ -17,24 +20,22 @@ export const useDashboardStore = create()((set, get) => ({ selectedNodeId: null, searchQuery: "", searchResults: [], + searchEngine: null, - setGraph: (graph) => set({ graph }), + setGraph: (graph) => { + const searchEngine = new SearchEngine(graph.nodes); + const query = get().searchQuery; + const searchResults = query.trim() ? searchEngine.search(query) : []; + set({ graph, searchEngine, searchResults }); + }, selectNode: (nodeId) => set({ selectedNodeId: nodeId }), setSearchQuery: (query) => { - const graph = get().graph; - if (!graph || !query.trim()) { + const engine = get().searchEngine; + if (!engine || !query.trim()) { set({ searchQuery: query, searchResults: [] }); return; } - const lower = query.toLowerCase(); - const results = graph.nodes - .filter( - (node) => - node.name.toLowerCase().includes(lower) || - node.summary.toLowerCase().includes(lower) || - node.tags.some((tag) => tag.toLowerCase().includes(lower)), - ) - .map((n) => n.id); - set({ searchQuery: query, searchResults: results }); + const searchResults = engine.search(query); + set({ searchQuery: query, searchResults }); }, }));