zebflow 0.7.9

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { createContext, useContext, useEffect, useRef, useState } from "zeb";
import FileSearchDialog from "@/pages/project-studio/components/file-search-dialog";
import type { FileItem } from "@/pages/project-studio/components/file-search-dialog";
import { registerShortcut } from "@/pages/project-studio/components/keyboard-shortcuts";

export interface FileSearchOpenOpts {
  scope?: "pages" | "all";
  onSelect?: (relPath: string) => void;
}

interface FileSearchContextValue {
  openFileSearch: (opts?: FileSearchOpenOpts) => void;
}

const FileSearchContext = createContext(null as FileSearchContextValue | null);

export function FileSearchProvider({ children, owner, project }) {
  const [open, setOpen] = useState(false);
  const [opts, setOpts] = useState<FileSearchOpenOpts>({});
  const [items, setItems] = useState<FileItem[]>([]);

  // Stable ref to the open handler so shortcuts can reference it after registration
  const handleOpenRef = useRef<(o?: FileSearchOpenOpts) => void>(null);

  async function loadItems() {
    if (!owner || !project) return;
    try {
      const [tmplResp, plResp] = await Promise.all([
        fetch(`/api/projects/${owner}/${project}/templates/workspace`),
        fetch(`/api/projects/${owner}/${project}/pipelines?recursive=true`),
      ]);
      const tmplData = tmplResp.ok ? await tmplResp.json() : {};
      const plData = plResp.ok ? await plResp.json() : {};

      const templateItems: FileItem[] = (Array.isArray(tmplData?.items) ? tmplData.items : [])
        .filter((item: any) => item?.kind !== "folder" && !!item?.rel_path)
        .map((item: any) => ({
          rel_path: String(item.rel_path),
          name: String(item.name || item.rel_path),
        }));

      const pipelineItems: FileItem[] = (Array.isArray(plData?.items) ? plData.items : [])
        .filter((item: any) => !!item?.meta?.file_rel_path)
        .map((item: any) => ({
          rel_path: String(item.meta.file_rel_path),
          name: String(item.meta.name || item.meta.file_rel_path),
        }));

      setItems([...templateItems, ...pipelineItems]);
    } catch {
      // ignore
    }
  }

  function handleOpen(o: FileSearchOpenOpts = {}) {
    setOpts(o);
    setOpen(true);
    loadItems();
  }

  // Keep ref up to date so the shortcut action always calls the latest version
  handleOpenRef.current = handleOpen;

  // Register Cmd+K / Ctrl+K — once, via stable ref
  useEffect(() => {
    registerShortcut({
      key: "k",
      meta: true,
      description: "Open file search",
      action: () => handleOpenRef.current?.(),
    });
    registerShortcut({
      key: "k",
      ctrl: true,
      description: "Open file search",
      action: () => handleOpenRef.current?.(),
    });
  }, []);

  const value: FileSearchContextValue = { openFileSearch: handleOpen };

  return (
    <FileSearchContext.Provider value={value}>
      {children}
      <FileSearchDialog
        open={open}
        onClose={() => setOpen(false)}
        onSelect={(relPath) => { opts.onSelect?.(relPath); setOpen(false); }}
        owner={owner}
        project={project}
        scope={opts.scope}
        items={items}
      />
    </FileSearchContext.Provider>
  );
}

export function useFileSearch(): FileSearchContextValue {
  const ctx = useContext(FileSearchContext);
  if (!ctx) throw new Error("useFileSearch must be used inside FileSearchProvider");
  return ctx;
}

/** Returns null when not inside FileSearchProvider — use for optional integration. */
export function useFileSearchOptional(): FileSearchContextValue | null {
  return useContext(FileSearchContext);
}