367 lines · 11.1 KB · TypeScript
RawDownload
import { useEffect, useMemo, useRef, useState } from "react"; // ---------------------------------------------------------------------------// Types// --------------------------------------------------------------------------- export interface ToolSummary {  readonly id: string;  readonly name: string;  readonly pluginKey: string;  readonly description?: string;} type TreeNode = {  segment: string;  tool?: ToolSummary;  children: Map<string, TreeNode>;}; // ---------------------------------------------------------------------------// 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 ? (          <mark key={i} className="rounded-sm bg-primary/20 px-px text-foreground">            {p.text}          </mark>        ) : (          <span key={i}>{p.text}</span>        ),      )}    </>  );}; // ---------------------------------------------------------------------------// 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 (      <ToolLeafItem        tool={node.tool!}        active={node.tool!.id === selectedToolId}        onSelect={() => 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 (    <div>      <button        type="button"        onClick={() => setOpen((v) => !v)}        aria-expanded={open}        className="group flex h-auto w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] hover:bg-accent/40 text-left"        style={{ paddingLeft }}      >        <svg          viewBox="0 0 8 8"          className="size-2 shrink-0 text-muted-foreground/30 transition-transform duration-150"          style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)" }}        >          <path d="M2 1l4 3-4 3z" fill="currentColor" />        </svg>        <svg viewBox="0 0 16 16" className="size-3 shrink-0 text-muted-foreground/30">          <path            d="M2 4h5l2 2h5v7H2V4z"            fill="none"            stroke="currentColor"            strokeWidth="1.2"          />        </svg>        <span className="flex-1 truncate font-mono text-foreground/70">          {highlightMatch(node.segment, search)}        </span>        <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25">          {leafCount}        </span>      </button>       {open && hasChildren && (        <div className="relative flex flex-col gap-px">          <span            className="absolute bottom-1 top-0 w-px bg-border/40"            style={{ left: paddingLeft + 5 }}            aria-hidden          />          {sorted.map((child) => (            <TreeNodeView              key={child.segment}              node={child}              depth={depth + 1}              selectedToolId={selectedToolId}              onSelect={onSelect}              search={search}              defaultOpen={defaultOpen}            />          ))}        </div>      )}    </div>  );} // ---------------------------------------------------------------------------// Leaf item// --------------------------------------------------------------------------- function ToolLeafItem(props: {  tool: ToolSummary;  active: boolean;  onSelect: () => void;  search: string;  depth: number;}) {  const ref = useRef<HTMLButtonElement>(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 (    <button      ref={ref}      type="button"      onClick={props.onSelect}      className={[        "group flex h-auto w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left",        props.active          ? "border-l-2 border-l-primary bg-primary/10 text-foreground"          : "text-foreground/70 hover:bg-accent/50 hover:text-foreground",      ].join(" ")}      style={{ paddingLeft }}    >      <svg viewBox="0 0 16 16" className="size-3 shrink-0 text-muted-foreground/40">        <path          d="M4 2h8l1 3H3l1-3zM3 6h10v8H3V6z"          fill="none"          stroke="currentColor"          strokeWidth="1.2"        />      </svg>      <span className="flex-1 truncate font-mono text-[12px]">        {highlightMatch(label, props.search)}      </span>    </button>  );} // ---------------------------------------------------------------------------// ToolTree — main export// --------------------------------------------------------------------------- export function ToolTree(props: {  tools: readonly ToolSummary[];  selectedToolId: string | null;  onSelect: (toolId: string) => void;}) {  const [search, setSearch] = useState("");  const searchRef = useRef<HTMLInputElement>(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 (    <div className="flex h-full flex-col">      {/* Search */}      <div className="shrink-0 border-b border-border px-3 py-2">        <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5">          <svg viewBox="0 0 16 16" className="size-3.5 shrink-0 text-muted-foreground/40">            <circle cx="6.5" cy="6.5" r="5" fill="none" stroke="currentColor" strokeWidth="1.2" />            <path d="M10 10l4.5 4.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />          </svg>          <input            ref={searchRef}            value={search}            onChange={(e) => 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"          />          {search.length > 0 ? (            <button              type="button"              onClick={() => setSearch("")}              className="size-5 shrink-0 flex items-center justify-center text-muted-foreground/40 hover:text-foreground"            >              ×            </button>          ) : (            <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50">              /            </kbd>          )}        </div>      </div>       {/* Tree */}      <div className="flex-1 overflow-y-auto p-1.5">        {filteredTools.length === 0 ? (          <div className="p-4 text-center text-[13px] text-muted-foreground/50">            {terms.length > 0 ? "No tools match your filter" : "No tools available"}          </div>        ) : (          <div className="flex flex-col gap-px">            {entries.map((node) => (              <TreeNodeView                key={node.segment}                node={node}                depth={0}                selectedToolId={props.selectedToolId}                onSelect={props.onSelect}                search={search}                defaultOpen={terms.length > 0}              />            ))}          </div>        )}      </div>    </div>  );}