zebflow 0.6.1

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import HierarchyTree, { HierarchyTreeItem } from "@/components/ui/hierarchy-tree";

function getParentDir(relPath) {
  if (!relPath || !String(relPath).includes("/")) {
    return "";
  }
  const path = String(relPath);
  return path.slice(0, path.lastIndexOf("/"));
}

function ReactFileIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="template-tech-icon is-react" aria-hidden="true">
      <circle cx="12" cy="12" r="1.6" fill="currentColor" />
      <ellipse cx="12" cy="12" rx="8.4" ry="3.4" stroke="currentColor" strokeWidth="1.6" />
      <ellipse cx="12" cy="12" rx="8.4" ry="3.4" stroke="currentColor" strokeWidth="1.6" transform="rotate(60 12 12)" />
      <ellipse cx="12" cy="12" rx="8.4" ry="3.4" stroke="currentColor" strokeWidth="1.6" transform="rotate(-60 12 12)" />
    </svg>
  );
}

function TsFileIcon() {
  return (
    <span className="template-tech-icon is-ts" aria-hidden="true">
      TS
    </span>
  );
}

function CssFileIcon() {
  return (
    <span className="template-tech-icon is-css" aria-hidden="true">
      CSS
    </span>
  );
}

function MdFileIcon() {
  return (
    <span className="template-tech-icon is-md" aria-hidden="true">
      MD
    </span>
  );
}

function FolderIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="template-tech-icon is-folder" aria-hidden="true">
      <path d="M4 7h6l2 2h8v8H4z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
    </svg>
  );
}

function iconForTemplateItem(item) {
  const relPath = String(item?.rel_path || "").toLowerCase();
  const isTsx = relPath.endsWith(".tsx");
  const isTs = relPath.endsWith(".ts");
  const isCss = relPath.endsWith(".css");
  const isMd = relPath.endsWith(".md") || relPath.endsWith(".mdx");

  if (item?.kind === "folder") return <FolderIcon />;
  if (isTsx || item?.file_kind === "page" || item?.file_kind === "component") return <ReactFileIcon />;
  if (isTs || item?.file_kind === "script") return <TsFileIcon />;
  if (isCss || item?.file_kind === "style") return <CssFileIcon />;
  if (isMd) return <MdFileIcon />;
  return <i className="zf-devicon zf-icon-file" aria-hidden="true"></i>;
}

function toTemplateHierarchy(items, selectedPath, onSelectFile, onSelectFolder) {
  const safeItems = Array.isArray(items) ? items : [];
  const byPath = new Map();
  const roots = [];

  const sorted = [...safeItems].sort((a, b) => {
    const aKind = String(a?.kind || "");
    const bKind = String(b?.kind || "");
    if (aKind !== bKind) {
      if (aKind === "folder") return -1;
      if (bKind === "folder") return 1;
    }
    return String(a?.rel_path || "").localeCompare(String(b?.rel_path || ""));
  });

  sorted.forEach((item) => {
    const relPath = String(item?.rel_path || "");
    const isFolder = String(item?.kind || "") === "folder";
    const isSelected = relPath === selectedPath;
    const rowClassName = cx(
      "template-tree-item",
      isFolder ? "is-folder" : "",
      item?.is_protected ? "is-protected" : "",
      isSelected ? "is-selected" : ""
    );

    const node: HierarchyTreeItem = {
      id: relPath || `${item?.kind || "node"}-${String(item?.name || "")}`,
      icon: <span className="template-tree-icon">{iconForTemplateItem(item)}</span>,
      label: <span className="template-tree-label">{item?.name || "item"}</span>,
      badge: <span className="template-tree-git"></span>,
      className: "",
      rowClassName,
      expanded: isFolder,
      children: [],
      onClick: isFolder ? () => onSelectFolder(relPath) : undefined,
    };

    if (!isFolder) {
      node.content = (
        <div
          className={cx("project-tree-leaf-link", rowClassName)}
          onClick={() => onSelectFile(relPath)}
        >
          <span className="template-tree-icon">{iconForTemplateItem(item)}</span>
          <span className="template-tree-label">{item?.name || "item"}</span>
          {item?.is_protected ? (
            <span className="template-tree-lock" title="Protected">
              <svg viewBox="0 0 24 24" fill="none" className="w-3.5 h-3.5">
                <path d="M8 11V8a4 4 0 118 0v3M7 11h10v9H7z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
              </svg>
            </span>
          ) : null}
          <span className="template-tree-git"></span>
        </div>
      );
    }

    byPath.set(relPath, node);
  });

  sorted.forEach((item) => {
    const relPath = String(item?.rel_path || "");
    const parentPath = getParentDir(relPath);
    const node = byPath.get(relPath);
    if (!node) return;
    if (parentPath && byPath.has(parentPath)) {
      const parent = byPath.get(parentPath);
      if (parent) {
        parent.children = [...(parent.children || []), node];
        return;
      }
    }
    roots.push(node);
  });

  const sortNodeChildren = (nodes) =>
    (Array.isArray(nodes) ? nodes : [])
      .map((node) => ({
        ...node,
        children: sortNodeChildren(node.children),
      }))
      .sort((a, b) => {
        const aFolder = Array.isArray(a?.children) && a.children.length > 0;
        const bFolder = Array.isArray(b?.children) && b.children.length > 0;
        if (aFolder !== bFolder) {
          return aFolder ? -1 : 1;
        }
        return String(a?.id || "").localeCompare(String(b?.id || ""));
      });

  return sortNodeChildren(roots);
}

interface TemplateFolderTreeProps {
  items?: any[];
  selectedPath?: string;
  onSelectFile?: (relPath: string) => void;
  onSelectFolder?: (relPath: string) => void;
}

export default function TemplateFolderTree(props: TemplateFolderTreeProps) {
  const nodes = toTemplateHierarchy(
    props?.items || [],
    props?.selectedPath || "",
    props?.onSelectFile || (() => {}),
    props?.onSelectFolder || (() => {})
  );
  return (
    <HierarchyTree
      items={nodes}
      className="template-tree-hierarchy"
      emptyLabel="No templates yet."
      defaultExpandedDepth={3}
    />
  );
}