zebflow 0.10.2

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { useState, useEffect, useRef } from "zeb/react";
import { Dialog } from "@/components/ui/dialog";
import DialogContent from "@/components/ui/dialog-content";

export interface FileItem {
  rel_path: string;
  name: string;
}

interface SearchMatch {
  rel_path: string;
  line: number;
  snippet: string;
}

interface ResultItem {
  relPath: string;
  label: string;
  sub?: string;
}

interface Props {
  open: boolean;
  onClose: () => void;
  onSelect: (relPath: string) => void;
  owner: string;
  project: string;
  scope?: "pages" | "all";
  items: FileItem[];
}

export default function FileSearchDialog({ open, onClose, onSelect, owner, project, scope, items }: Props) {
  const [mode, setMode] = useState<"files" | "search">("files");
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<ResultItem[]>([]);
  const [cursor, setCursor] = useState(0);
  const [loading, setLoading] = useState(false);
  const inputRef = useRef(null);
  const listRef = useRef(null);

  // Reset on open
  useEffect(() => {
    if (open) {
      setQuery("");
      setCursor(0);
      setMode("files");
      setLoading(false);
    }
  }, [open]);

  // Auto-focus input
  useEffect(() => {
    if (!open) return;
    const t = setTimeout(() => (inputRef.current as any)?.focus(), 40);
    return () => clearTimeout(t);
  }, [open]);

  // Files mode — filter locally
  useEffect(() => {
    if (mode !== "files") return;
    const q = query.trim().toLowerCase();
    const filtered = items
      .filter((item) => scope !== "pages" || item.rel_path.startsWith("pages/"))
      .filter((item) => !q || item.name.toLowerCase().includes(q) || item.rel_path.toLowerCase().includes(q))
      .slice(0, 60)
      .map((item) => ({
        relPath: item.rel_path,
        label: item.name || item.rel_path,
        sub: item.rel_path,
      }));
    setResults(filtered);
    setCursor(0);
  }, [mode, query, items, scope]);

  // Search mode — debounced grep
  useEffect(() => {
    if (mode !== "search") return;
    const q = query.trim();
    if (!q) { setResults([]); setLoading(false); return; }
    setLoading(true);
    const t = setTimeout(async () => {
      try {
        const params = new URLSearchParams({ q });
        const resp = await fetch(`/api/projects/${owner}/${project}/repo/search?${params}`);
        const data = await resp.json();
        const matches: SearchMatch[] = Array.isArray(data?.matches) ? data.matches : [];
        setResults(
          matches.slice(0, 60).map((m) => ({
            relPath: m.rel_path,
            label: m.rel_path,
            sub: `line ${m.line}: ${m.snippet.trim().slice(0, 80)}`,
          }))
        );
        setCursor(0);
      } catch {
        setResults([]);
      } finally {
        setLoading(false);
      }
    }, 250);
    return () => { clearTimeout(t); setLoading(false); };
  }, [mode, query, owner, project, scope]);

  // Keyboard navigation
  useEffect(() => {
    if (!open) return;
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setCursor((c) => Math.min(c + 1, results.length - 1));
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setCursor((c) => Math.max(c - 1, 0));
      } else if (e.key === "Enter") {
        const r = results[cursor];
        if (r) { e.preventDefault(); onSelect(r.relPath); onClose(); }
      }
    }
    window.addEventListener("keydown", onKey, { capture: true });
    return () => window.removeEventListener("keydown", onKey, { capture: true });
  }, [open, results, cursor, onClose, onSelect]);

  // Scroll active result into view
  useEffect(() => {
    if (!listRef.current) return;
    const el = (listRef.current as HTMLElement).querySelector<HTMLElement>(`[data-idx="${cursor}"]`);
    el?.scrollIntoView({ block: "nearest" });
  }, [cursor]);

  if (!open) return null;

  return (
    <Dialog open={open} onOpenChange={(value) => { if (!value) onClose(); }}>
      <DialogContent className="max-w-xl border-border bg-background text-muted-foreground">
          {/* Input */}
          <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
            <DlgSearchIcon />
            <input
              ref={inputRef}
              value={query}
              onInput={(e: any) => setQuery(e.target.value)}
              placeholder={mode === "files" ? "Find file by name…" : "Search file contents…"}
              className="flex-1 min-w-0 bg-transparent text-sm text-muted-foreground placeholder-muted-foreground/40 outline-none"
            />
            {loading && <DlgSpinIcon />}
          </div>
          {/* Mode tabs */}
          <div className="flex gap-1.5 px-3 py-2 border-b border-border">
            <button
              onClick={() => { setMode("files"); setQuery(""); }}
              className={`text-xs px-2.5 py-0.5 rounded-full transition-colors ${
                mode === "files"
                  ? "bg-blue-600 text-white"
                  : "text-muted-foreground/60 hover:bg-success hover:text-muted-foreground"
              }`}
            >
              Files
            </button>
            <button
              onClick={() => { setMode("search"); setQuery(""); }}
              className={`text-xs px-2.5 py-0.5 rounded-full transition-colors ${
                mode === "search"
                  ? "bg-blue-600 text-white"
                  : "text-muted-foreground/60 hover:bg-success hover:text-muted-foreground"
              }`}
            >
              Search
            </button>
          </div>
          {/* Results */}
          <div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
            {results.length === 0 && !loading && (
              <p className="py-5 text-center text-xs text-muted-foreground/40">
                {mode === "files"
                  ? query.trim() ? "No files match" : "Type to filter files"
                  : query.trim() ? "No matches found" : "Type to search file contents"}
              </p>
            )}
            {results.map((r, i) => {
              const isPipeline = r.relPath.endsWith(".zf.json");
              return (
                <button
                  key={`result-${i}`}
                  data-idx={i}
                  onClick={() => { onSelect(r.relPath); onClose(); }}
                  className={`w-full text-left px-3 py-2 flex flex-col gap-0.5 transition-colors ${
                    i === cursor
                      ? "bg-blue-600/20 text-muted-foreground"
                      : "text-muted-foreground/80 hover:bg-success hover:text-muted-foreground"
                  }`}
                >
                  <div className="flex items-center gap-2 min-w-0">
                    <span className="text-sm font-medium truncate flex-1">{r.label}</span>
                    <span className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded font-medium ${
                      isPipeline
                        ? "bg-[#c46255]/20 text-[#f6863c]"
                        : "bg-sky-500/20 text-sky-400"
                    }`}>
                      {isPipeline ? "pipeline" : "template"}
                    </span>
                  </div>
                  {r.sub && r.sub !== r.label && (
                    <span className="text-xs text-muted-foreground/40 truncate">{r.sub}</span>
                  )}
                </button>
              );
            })}
          </div>
          {/* Footer hints */}
          <div className="flex items-center gap-3 px-3 py-1.5 border-t border-border text-[11px] text-muted-foreground/30 select-none">
            <span>↑↓ navigate</span>
            <span>↵ select</span>
            <span>Esc close</span>
          </div>
      </DialogContent>
    </Dialog>
  );
}

function DlgSearchIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="w-4 h-4 shrink-0 text-muted-foreground/40">
      <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
      <path d="M17 17l4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}

function DlgSpinIcon() {
  return (
    <svg className="w-3.5 h-3.5 animate-spin text-muted-foreground/40" viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.3" />
      <path fill="currentColor" opacity="0.8" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
    </svg>
  );
}