381 lines · 11.3 KB · TypeScript
RawDownload
import { useEffect, useMemo, useRef, useState } from "react";import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react";import { Button } from "./button";import { Input } from "./input";import { cn } from "../lib/utils"; // ---------------------------------------------------------------------------// Types// --------------------------------------------------------------------------- export interface ToolSummary {  readonly id: string;  readonly name: string;  readonly pluginKey: string;  readonly description?: string;} type TreeNode = {  segment: string;  path: string;  tool?: ToolSummary;  children: Map<string, TreeNode>;}; type Row =  | { kind: "leaf"; depth: number; path: string; tool: ToolSummary }  | {      kind: "group";      depth: number;      path: string;      segment: string;      count: number;      open: boolean;    }; // ---------------------------------------------------------------------------// Tree builder// --------------------------------------------------------------------------- const buildTree = (tools: readonly ToolSummary[]): TreeNode => {  const root: TreeNode = { segment: "", path: "", children: new Map() };   for (const tool of tools) {    const parts = tool.name.split(".");    let node = root;    let path = "";    for (const part of parts) {      path = path ? `${path}.${part}` : part;      let child = node.children.get(part);      if (!child) {        child = { segment: part, path, 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;}; const collectGroupPaths = (node: TreeNode, acc: Set<string>): void => {  for (const child of node.children.values()) {    if (child.children.size > 0) {      acc.add(child.path);      collectGroupPaths(child, acc);    }  }}; const flattenTree = (  node: TreeNode,  depth: number,  openSet: ReadonlySet<string>,  acc: Row[],): void => {  const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment));  for (const child of sorted) {    const hasChildren = child.children.size > 0;    const isLeaf = !!child.tool && !hasChildren;     if (isLeaf) {      acc.push({ kind: "leaf", depth, path: child.path, tool: child.tool! });      continue;    }     const open = openSet.has(child.path);    acc.push({      kind: "group",      depth,      path: child.path,      segment: child.segment,      count: countLeaves(child),      open,    });    if (open) {      flattenTree(child, depth + 1, openSet, acc);    }  }}; // ---------------------------------------------------------------------------// 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 ? (          <mark key={i} className="rounded-sm bg-primary/25 px-px text-foreground">            {p.text}          </mark>        ) : (          <span key={i}>{p.text}</span>        ),      )}    </>  );}; // ---------------------------------------------------------------------------// ToolTree — main export// --------------------------------------------------------------------------- export function ToolTree(props: {  tools: readonly ToolSummary[];  selectedToolId: string | null;  onSelect: (toolId: string) => void;}) {  const { tools, selectedToolId, onSelect } = props;  const [search, setSearch] = useState("");  const [manualOpen, setManualOpen] = useState<Set<string>>(() => new Set());  const searchRef = useRef<HTMLInputElement>(null);  const selectedRowRef = useRef<HTMLButtonElement>(null);   const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);   const filteredTools = useMemo(() => {    if (terms.length === 0) return tools;    return tools.filter((t) => {      const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase();      return terms.every((term) => corpus.includes(term));    });  }, [tools, terms]);   const tree = useMemo(() => buildTree(filteredTools), [filteredTools]);   // When searching, expand everything so matches are visible.  // Also auto-expand groups that contain the selected tool.  const openSet = useMemo(() => {    if (terms.length > 0) {      const all = new Set<string>();      collectGroupPaths(tree, all);      return all;    }    const set = new Set(manualOpen);    if (selectedToolId) {      const parts = selectedToolId.split(".");      // Progressively add ancestor paths (best-effort, based on dotted name).      let acc = "";      for (let i = 0; i < parts.length - 1; i++) {        acc = acc ? `${acc}.${parts[i]}` : parts[i]!;        set.add(acc);      }    }    return set;  }, [tree, manualOpen, selectedToolId, terms.length]);   const rows = useMemo(() => {    const acc: Row[] = [];    flattenTree(tree, 0, openSet, acc);    return acc;  }, [tree, openSet]);   const toggleGroup = (path: string) => {    setManualOpen((prev) => {      const next = new Set(prev);      if (next.has(path)) next.delete(path);      else next.add(path);      return next;    });  };   // Keyboard shortcuts — `/` focuses search, Escape clears  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]);   // Scroll the selected row into view when it changes  useEffect(() => {    if (!selectedToolId) return;    selectedRowRef.current?.scrollIntoView({ block: "nearest" });  }, [selectedToolId, rows]);   return (    <div className="flex h-full min-h-0 flex-col bg-muted/20">      <div className="flex shrink-0 items-center gap-2 px-3 py-2">        <SearchIcon aria-hidden className="size-3 shrink-0 text-muted-foreground" />        <Input          ref={searchRef}          value={search}          onChange={(e) => setSearch(e.target.value)}          placeholder={`Filter ${tools.length} tools…`}          aria-label="Filter tools"          className="h-auto min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-xs shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0"        />        {search.length > 0 && (          <Button            variant="ghost"            size="icon-xs"            onClick={() => setSearch("")}            aria-label="Clear search"            className="size-4 shrink-0 text-muted-foreground hover:text-foreground"          >            <XIcon className="size-3" />          </Button>        )}      </div>      <div className="mx-2 border-t border-border/30" />      <div className="min-h-0 flex-1 overflow-y-auto">        {filteredTools.length === 0 ? (          <div className="p-4 text-center text-xs text-muted-foreground">            {terms.length > 0 ? "No tools match your filter" : "No tools available"}          </div>        ) : (          rows.map((row) =>            row.kind === "leaf" ? (              <ToolLeafRow                key={row.path}                buttonRef={row.tool.id === selectedToolId ? selectedRowRef : undefined}                tool={row.tool}                depth={row.depth}                active={row.tool.id === selectedToolId}                onSelect={() => onSelect(row.tool.id)}                search={search}              />            ) : (              <ToolGroupRow                key={row.path}                segment={row.segment}                depth={row.depth}                count={row.count}                open={row.open}                onToggle={() => toggleGroup(row.path)}                search={search}              />            ),          )        )}      </div>    </div>  );} // ---------------------------------------------------------------------------// Row renderers// --------------------------------------------------------------------------- const rowIndent = (depth: number) => 12 + depth * 16; const rowBaseClasses =  "relative flex h-auto w-full items-center justify-start gap-2 rounded-none py-2 text-xs font-normal transition-[background-color] duration-150"; function ToolGroupRow(props: {  segment: string;  depth: number;  count: number;  open: boolean;  onToggle: () => void;  search: string;}) {  return (    <Button      variant="ghost"      aria-expanded={props.open}      onClick={props.onToggle}      className={cn(rowBaseClasses, "hover:bg-accent/60")}      style={{ paddingLeft: rowIndent(props.depth), paddingRight: 12 }}    >      <ChevronRightIcon        aria-hidden        className={cn(          "size-3.5 shrink-0 text-muted-foreground transition-transform duration-150",          props.open && "rotate-90",        )}      />      <span className="min-w-0 flex-1 truncate text-left font-mono text-xs text-foreground">        {highlightMatch(props.segment, props.search)}      </span>      <span className="shrink-0 tabular-nums text-xs text-muted-foreground">{props.count}</span>    </Button>  );} function ToolLeafRow(props: {  buttonRef?: React.Ref<HTMLButtonElement>;  tool: ToolSummary;  depth: number;  active: boolean;  onSelect: () => void;  search: string;}) {  const label = props.tool.name.split(".").pop() ?? props.tool.name;  return (    <Button      ref={props.buttonRef}      variant="ghost"      onClick={props.onSelect}      className={cn(        rowBaseClasses,        props.active          ? "bg-primary/15 text-foreground ring-1 ring-inset ring-primary/40 hover:bg-primary/20"          : "text-foreground/80 hover:bg-accent/60 hover:text-foreground",      )}      style={{ paddingLeft: rowIndent(props.depth) + 20, paddingRight: 12 }}    >      <span className="flex-1 truncate text-left font-mono">        {highlightMatch(label, props.search)}      </span>    </Button>  );}