leankg 0.19.31

Lightweight Knowledge Graph for AI-Assisted Development
/**
 * FR-E33 search — local filter over loaded graph nodes (qualified name,
 * element type, file path). Instant, no extra backend round-trip.
 */
import { useMemo, useState } from 'react';
import type { GraphData, GraphNode } from '../lib/types';

export function matchNode(node: GraphNode, query: string): boolean {
  const q = query.trim().toLowerCase();
  if (!q) return true;
  return (
    node.id.toLowerCase().includes(q) ||
    node.properties.name.toLowerCase().includes(q) ||
    node.properties.filePath.toLowerCase().includes(q) ||
    node.properties.elementType.toLowerCase().includes(q)
  );
}

export interface SearchResult {
  node: GraphNode;
  index: number;
}

export default function SearchPanel({
  graph,
  onSelect,
}: {
  graph: GraphData | null;
  onSelect: (id: string) => void;
}) {
  const [query, setQuery] = useState('');
  const [selected, setSelected] = useState<string | null>(null);

  const results: SearchResult[] = useMemo(() => {
    if (!graph) return [];
    return graph.nodes
      .map((node, index) => ({ node, index }))
      .filter(({ node }) => matchNode(node, query))
      .slice(0, 50);
  }, [graph, query]);

  return (
    <section className="panel" data-testid="search-panel" aria-label="Search">
      <h2 className="panel-title">Search</h2>
      <input
        className="search-input"
        type="search"
        placeholder="Filter nodes by name, file, type…"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setSelected(null);
        }}
        aria-label="Search graph nodes"
      />
      <ul className="search-results">
        {results.map(({ node, index }) => (
          <li key={node.id}>
            <button
              className={`search-result${selected === node.id ? ' selected' : ''}`}
              onClick={() => {
                setSelected(node.id);
                onSelect(node.id);
              }}
            >
              <span className="search-name">{node.properties.name || node.id}</span>
              <span className="search-file">{node.properties.filePath}</span>
              <span className="search-type">{node.properties.elementType}</span>
              <span className="search-rank">{index + 1}</span>
            </button>
          </li>
        ))}
      </ul>
      {results.length === 0 && query && <p className="panel-muted">No matches.</p>}
    </section>
  );
}