zebflow 0.10.10

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { useEffect, useState } from "zeb/react";
import RepoTreeRow from "@/components/ui/repo-tree-row";
import { ancestorsOf, repoTree } from "@/components/lib/repo-tree";

/** The repository itself, drawn as the first row so it is not a special case. */
function rootItem(label) {
  return { name: label || "repository", rel_path: "", kind: "root" };
}

/**
 * One folder's contents, and the folders opened inside it.
 *
 * Recursive, and each level fetches only when it is opened — so drawing the
 * tree costs what the reader has actually looked at, not the whole repository.
 */
function Level({ path, depth, state, actions }) {
  const items = state.folders[path];

  useEffect(() => {
    actions.load(path);
  }, [path]);

  if (!items) {
    return (
      <p className="py-1 text-[11px] text-muted-foreground" style={{ paddingLeft: `${depth * 12 + 26}px` }}>
        Loading…
      </p>
    );
  }
  if (!items.length) {
    return (
      <p className="py-1 text-[11px] text-muted-foreground" style={{ paddingLeft: `${depth * 12 + 26}px` }}>
        Empty
      </p>
    );
  }

  return (
    <>
      {items.map((item) => {
        const open = !!state.expanded[item.rel_path];
        return (
          <div key={item.rel_path}>
            <RepoTreeRow
              item={item}
              depth={depth}
              open={open}
              active={state.selected === item.rel_path}
              status={state.gitStatus[item.rel_path]}
              menu={actions.menuFor(item)}
              onActivate={actions.activate}
            />
            {item.kind === "folder" && open ? (
              <Level path={item.rel_path} depth={depth + 1} state={state} actions={actions} />
            ) : null}
          </div>
        );
      })}
    </>
  );
}

/**
 * The repository as a tree the reader opens a folder at a time.
 *
 * Every row — the root included — carries the same `⋯`, so "what can I do
 * here" has one answer and one place to find it. Selecting a folder is how you
 * say where the next thing should be made.
 *
 * `refreshToken` is how a caller says something was written: change it and the
 * folders already on screen are re-read. Nothing else is.
 */
export default function RepoTree({ owner, project, rootLabel, selected, onSelect, onAction, refreshToken }) {
  const tree = repoTree(owner, project);
  const [folders, setFolders] = useState({});
  const [expanded, setExpanded] = useState({ "": true });
  const [gitStatus, setGitStatus] = useState({});

  const root = rootItem(rootLabel);

  async function load(path: string, force = false) {
    if (force) tree.invalidate(path);
    else if (folders[path]) return;
    const items = await tree.children(path);
    setFolders((prev) => ({ ...prev, [path]: items }));
  }

  // Opening a file deep in the tree should show it rather than leave the
  // reader to go looking: every folder on the way down is opened.
  useEffect(() => {
    if (!selected) return;
    const parents = ancestorsOf(selected);
    if (!parents.length) return;
    setExpanded((prev) => {
      const next = { ...prev };
      for (const parent of parents) next[parent] = true;
      return next;
    });
  }, [selected]);

  // Which files git considers changed. One request for the repository, because
  // that is the only shape the question has — git does not answer it per folder.
  useEffect(() => {
    let live = true;
    tree
      .gitStatus()
      .then((map) => {
        if (live) setGitStatus(map);
      })
      .catch(() => {
        // A repository without git is not an error worth showing in a tree.
      });
    return () => {
      live = false;
    };
  }, [refreshToken]);

  // Something was written. Re-read the folders already on screen — at most what
  // the reader has opened, never the whole repository.
  useEffect(() => {
    if (!refreshToken) return;
    tree.invalidateAll();
    for (const path of Object.keys(folders)) load(path, true);
  }, [refreshToken]);

  function refreshAll() {
    tree.invalidateAll();
    for (const path of Object.keys(folders)) load(path, true);
  }

  const CREATE = [
    { kind: "new-file", label: "New file" },
    { kind: "new-pipeline", label: "New pipeline" },
    { kind: "new-folder", label: "New folder" },
  ];

  const actions = {
    load,
    /** A click picks the row; a folder or the root also opens or closes. */
    activate: (item) => {
      onSelect?.(item);
      if (item.kind === "folder" || item.kind === "root") {
        setExpanded((prev) => ({ ...prev, [item.rel_path]: !prev[item.rel_path] }));
      }
    },
    menuFor: (item) => {
      const inHere = (kind: string) => () => {
        // Open first: a new file appearing inside a closed folder looks like
        // nothing happened.
        setExpanded((prev) => ({ ...prev, [item.rel_path]: true }));
        onAction?.(kind, item);
      };

      // Opening a folder is the first thing offered, because seeing what is
      // inside is what a reader most often wants from one — and the panel
      // beside the tree already knows how to list a folder.
      const open = { label: "Open folder", onSelect: () => onAction?.("open-folder", item) };
      const add = { label: "Add from hub…", onSelect: inHere("add") };

      if (item.kind === "root") {
        return [
          open,
          { separator: true },
          ...CREATE.map((entry) => ({ label: entry.label, onSelect: inHere(entry.kind) })),
          add,
          { separator: true },
          { label: "Refresh", onSelect: refreshAll },
          { label: "Collapse all", onSelect: () => setExpanded({ "": true }) },
        ];
      }

      if (item.kind === "folder") {
        return [
          open,
          { separator: true },
          ...CREATE.map((entry) => ({ label: `${entry.label} here`, onSelect: inHere(entry.kind) })),
          add,
          { separator: true },
          { label: "Rename…", onSelect: () => onAction?.("rename", item) },
          {
            label: item.is_protected ? "Declared by the layout" : "Delete folder",
            variant: item.is_protected ? undefined : "destructive",
            disabled: !!item.is_protected,
            onSelect: () => onAction?.("delete", item),
          },
        ];
      }

      return [
        { label: "Open", onSelect: () => onSelect?.(item) },
        { separator: true },
        { label: "Rename…", onSelect: () => onAction?.("rename", item) },
        { label: "Duplicate", onSelect: () => onAction?.("duplicate", item) },
        { label: "Delete file", variant: "destructive", onSelect: () => onAction?.("delete", item) },
      ];
    },
  };

  const state = { folders, expanded, selected, gitStatus };

  return (
    <div className="flex min-h-0 flex-1 flex-col overflow-y-auto py-1" data-repo-tree="true">
      <RepoTreeRow
        item={root}
        depth={0}
        open={!!expanded[""]}
        active={selected === ""}
        menu={actions.menuFor(root)}
        onActivate={actions.activate}
      />
      {expanded[""] ? <Level path="" depth={1} state={state} actions={actions} /> : null}
    </div>
  );
}