~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
143 lines · 4.5 KB Raw History
import {
  ChevronDownIcon,
  ChevronRightIcon,
  FileIcon,
  FolderIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link } from '#/components/atoms/link.jsx';
import { buildFileTree, filterFiles } from '#/lib/file-tree.js';
import { cn } from '#/lib/utils.js';

/** Whether a directory holds, or is, the path in view. */
const onPath = (dir, path) =>
  dir === path || path.startsWith(`${dir}/`) || dir.startsWith(`${path}/`);

/**
 * One row per node, indented by depth. A directory opens on its own state;
 * one on the way to the path in view opens by itself, so the branch a reader
 * is standing on is the one they see. A filter opens everything it matched.
 */
function Node({ node, depth, base, refName, path, selected, filter }) {
  const [manual, setManual] = useState(/** @type {boolean|null} */ (null));
  const pad = { paddingLeft: `${depth * 12 + 10}px` };

  if (node.type === 'file') {
    const current = node.path === selected;
    return (
      <Link
        href={`${base}/blob/${refName}/${encodeURI(node.path)}`}
        style={pad}
        aria-current={current ? 'page' : undefined}
        title={node.path}
        className={cn(
          'flex items-center gap-1.5 border-l-2 py-[5px] pr-2 text-[13px] no-underline hover:bg-accent hover:no-underline',
          current
            ? 'border-key bg-accent font-semibold text-foreground'
            : 'border-transparent text-muted-foreground hover:text-foreground',
        )}
      >
        <span className="size-3.5 shrink-0" />
        <FileIcon className="size-3.5 shrink-0 text-faint" />
        <span className="min-w-0 flex-1 truncate">{node.name}</span>
      </Link>
    );
  }

  const open = filter ? true : (manual ?? onPath(node.path, path));
  const current = node.path === selected;
  const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
  return (
    <div>
      <div
        style={pad}
        className={cn(
          'flex items-center gap-1.5 border-l-2 py-[5px] pr-2 text-[13px]',
          current
            ? 'border-key bg-accent font-semibold text-foreground'
            : 'border-transparent text-foreground hover:bg-accent',
        )}
      >
        <button
          type="button"
          onClick={() => setManual(!open)}
          aria-expanded={open}
          aria-label={open ? `Close ${node.name}` : `Open ${node.name}`}
          className="-m-1 cursor-pointer rounded-sm p-1 text-faint hover:text-foreground"
        >
          <Chevron className="size-3.5" />
        </button>
        <Link
          href={`${base}/tree/${refName}/${encodeURI(node.path)}`}
          title={node.path}
          className="flex min-w-0 flex-1 items-center gap-1.5 text-inherit no-underline hover:no-underline"
        >
          <FolderIcon className="size-3.5 shrink-0 text-key" />
          <span className="min-w-0 flex-1 truncate">{node.name}</span>
        </Link>
      </div>
      {open &&
        node.children.map((child) => (
          <Node
            key={child.path}
            node={child}
            depth={depth + 1}
            base={base}
            refName={refName}
            path={path}
            selected={selected}
            filter={filter}
          />
        ))}
    </div>
  );
}

/**
 * The whole repository as a tree, down the rail beside whatever is open:
 * every directory from the root, closed but for the ones on the way to the
 * path in view, the way a forge's file rail reads.
 *
 * `path` is the directory in view, which opens the branch leading to it.
 * `selected` is the row a reader is standing on, a file or the directory
 * itself. `filter` narrows the tree to the paths holding the text.
 * @param {{repo: string, refName: string, files: Array<{path: string}>, path: string, selected?: string, filter?: string}} props
 */
export function RepoTree({
  repo,
  refName,
  files,
  path,
  selected = '',
  filter = '',
}) {
  const base = `/${encodeURIComponent(repo)}`;
  const ref = encodeURI(refName);
  const roots = useMemo(
    () => buildFileTree(filterFiles(files, filter)),
    [files, filter],
  );
  if (roots.length === 0) {
    return (
      <p className="px-(--gutter) py-4 text-[12.5px] text-faint">
        {filter ? 'Nothing matches.' : 'No files.'}
      </p>
    );
  }
  return (
    <div className="py-1">
      {roots.map((node) => (
        <Node
          key={node.path}
          node={node}
          depth={0}
          base={base}
          refName={ref}
          path={path}
          selected={selected}
          filter={filter.trim()}
        />
      ))}
    </div>
  );
}