import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "./button"; import { Input } from "./input"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ToolSummary { readonly id: string; readonly name: string; readonly pluginKey: string; readonly description?: string; } type TreeNode = { segment: string; tool?: ToolSummary; children: Map; }; // --------------------------------------------------------------------------- // Tree builder // --------------------------------------------------------------------------- const buildTree = (tools: readonly ToolSummary[]): TreeNode => { const root: TreeNode = { segment: "", children: new Map() }; for (const tool of tools) { const parts = tool.name.split("."); let node = root; for (const part of parts) { let child = node.children.get(part); if (!child) { child = { segment: part, children: new Map() }; node.children.set(part, child); } node = child; } node.tool = tool; } return root; }; const countLeaves = (node: TreeNode): number => { let count = node.tool ? 1 : 0; for (const child of node.children.values()) { count += countLeaves(child); } return count; }; // --------------------------------------------------------------------------- // Highlight // --------------------------------------------------------------------------- const highlightMatch = (text: string, search: string) => { if (!search.trim()) return text; const terms = search.trim().toLowerCase().split(/\s+/); const lower = text.toLowerCase(); const ranges: [number, number][] = []; for (const term of terms) { let idx = 0; while (idx < lower.length) { const found = lower.indexOf(term, idx); if (found === -1) break; ranges.push([found, found + term.length]); idx = found + 1; } } if (ranges.length === 0) return text; ranges.sort((a, b) => a[0] - b[0]); const merged: [number, number][] = [ranges[0]!]; for (let i = 1; i < ranges.length; i++) { const last = merged[merged.length - 1]!; const cur = ranges[i]!; if (cur[0] <= last[1]) { last[1] = Math.max(last[1], cur[1]); } else { merged.push(cur); } } const parts: { text: string; hl: boolean }[] = []; let cursor = 0; for (const [start, end] of merged) { if (cursor < start) parts.push({ text: text.slice(cursor, start), hl: false }); parts.push({ text: text.slice(start, end), hl: true }); cursor = end; } if (cursor < text.length) parts.push({ text: text.slice(cursor), hl: false }); return ( <> {parts.map((p, i) => p.hl ? ( {p.text} ) : ( {p.text} ), )} ); }; // --------------------------------------------------------------------------- // Tree node view // --------------------------------------------------------------------------- function TreeNodeView(props: { node: TreeNode; depth: number; selectedToolId: string | null; onSelect: (toolId: string) => void; search: string; defaultOpen: boolean; }) { const { node, depth, selectedToolId, onSelect, search, defaultOpen } = props; const hasChildren = node.children.size > 0; const isLeaf = !!node.tool && !hasChildren; const hasSelectedDescendant = useMemo(() => { if (!selectedToolId) return false; const check = (n: TreeNode): boolean => { if (n.tool?.id === selectedToolId) return true; for (const child of n.children.values()) { if (check(child)) return true; } return false; }; return check(node); }, [node, selectedToolId]); const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant); useEffect(() => { if (defaultOpen || hasSelectedDescendant) setOpen(true); }, [defaultOpen, hasSelectedDescendant]); const paddingLeft = 8 + depth * 16; if (isLeaf) { return ( onSelect(node.tool!.id)} search={search} depth={depth} /> ); } const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); const leafCount = countLeaves(node); return (
{open && hasChildren && (
{sorted.map((child) => ( ))}
)}
); } // --------------------------------------------------------------------------- // Leaf item // --------------------------------------------------------------------------- function ToolLeafItem(props: { tool: ToolSummary; active: boolean; onSelect: () => void; search: string; depth: number; }) { const ref = useRef(null); const paddingLeft = 8 + props.depth * 16 + 8; const label = props.tool.name.split(".").pop() ?? props.tool.name; useEffect(() => { if (props.active && ref.current) { ref.current.scrollIntoView({ block: "nearest" }); } }, [props.active]); return ( ); } // --------------------------------------------------------------------------- // ToolTree — main export // --------------------------------------------------------------------------- export function ToolTree(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; onSelect: (toolId: string) => void; }) { const [search, setSearch] = useState(""); const searchRef = useRef(null); const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); const filteredTools = useMemo(() => { if (terms.length === 0) return props.tools; return props.tools.filter((t) => { const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); return terms.every((term) => corpus.includes(term)); }); }, [props.tools, terms]); const tree = useMemo(() => buildTree(filteredTools), [filteredTools]); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { e.preventDefault(); searchRef.current?.focus(); } if (e.key === "Escape") { searchRef.current?.blur(); if (search.length > 0) setSearch(""); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [search]); const entries = [...tree.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); return (
{/* Search */}
setSearch(e.target.value)} placeholder={`Filter ${props.tools.length} tools…`} className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] shadow-none outline-none placeholder:text-muted-foreground/40 h-auto rounded-none focus-visible:ring-0 focus-visible:border-transparent" /> {search.length > 0 ? ( ) : ( / )}
{/* Tree */}
{filteredTools.length === 0 ? (
{terms.length > 0 ? "No tools match your filter" : "No tools available"}
) : (
{entries.map((node) => ( 0} /> ))}
)}
); }