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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
148 lines · 5.2 KB Raw History
import { linkedPackagesOf, runnerOf } from '@pdsjs/git/rules';
import { useQuery } from '@tanstack/react-query';
import {
  CircleCheckIcon,
  CircleDotIcon,
  CodeIcon,
  GitPullRequestIcon,
  PackageIcon,
  SettingsIcon,
} from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Count } from '#/components/atoms/count.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { account, loadRepoSummary, repoConfig, repoRecord } from '#/lib/git.js';
import { useSession } from '#/lib/session.jsx';
import { cn } from '#/lib/utils.js';

/** What each section is called, for the tabs and for the heading above them. */
export const SECTION_TITLES = {
  code: 'Code',
  commits: 'Commits',
  pulls: 'Pull requests',
  issues: 'Issues',
  checks: 'Checks',
  packages: 'Packages',
  settings: 'Settings',
};

const ICONS = {
  code: CodeIcon,
  pulls: GitPullRequestIcon,
  issues: CircleDotIcon,
  checks: CircleCheckIcon,
  packages: PackageIcon,
  settings: SettingsIcon,
};

/**
 * The sections of one repository, on a band of their own between the heading
 * and whatever the open section puts above its list. A page that reached a
 * dead end before, a run or a package or a file, carries the way back to
 * everything else.
 *
 * Which sections exist comes from the repository's config record, one cheap
 * read that is already in hand from elsewhere in the app. Reading the runner's
 * check listing would answer the same question and cost a listing walk, which
 * a reader looking at a package should not pay for.
 */
export function RepoTabs({ repo, active, space }) {
  const record = repoRecord(repo, space);
  const { session } = useSession();
  const { data: config } = useQuery({
    queryKey: ['config', repo, space ?? ''],
    queryFn: () => repoConfig(repo),
    staleTime: Number.POSITIVE_INFINITY,
  });
  // The config of a repository in a space is in the space, which the public
  // getRecord does not reach. The record row the listing read carries its
  // summary fields, so the tabs read those.
  const effective = config ?? record;
  // The same summary the repos page counts from. It is warmed before the
  // listing paints, so a reader coming from there sees the counts at once;
  // a deep link pays for the read behind a skeleton. A repository in a space
  // is absent from the summary and shows no count.
  const { data: summary, isPending } = useQuery({
    queryKey: ['repo-summary'],
    queryFn: loadRepoSummary,
    staleTime: Number.POSITIVE_INFINITY,
  });
  const row = summary?.get(repo);
  const packages = linkedPackagesOf(effective);
  const base = `/${encodeURIComponent(repo)}`;

  // The bar scrolls where a narrow window cannot hold every section. The one
  // that is open is the one a reader needs to see, so it brings itself into
  // view rather than sitting off the end.
  const opened = useRef(null);
  useEffect(() => {
    opened.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
  }, []);

  // The history has no tab: it opens from the commit count on the home, the
  // way a forge's does, and reads as part of the code.
  const open = active === 'commits' ? 'code' : active;
  const tabs = [
    { key: 'code', href: base },
    {
      key: 'issues',
      href: `${base}/issues`,
      count: row?.openIssues,
      pending: isPending,
    },
    {
      key: 'pulls',
      href: `${base}/pulls`,
      count: row?.openPulls,
      pending: isPending,
    },
    ...(runnerOf(effective) ? [{ key: 'checks', href: `${base}/checks` }] : []),
    ...(packages.length > 0
      ? [{ key: 'packages', href: `${base}/packages`, count: packages.length }]
      : []),
    // The settings are the owner's, and every write runs under their own
    // grant, so the tab is theirs alone.
    ...(session?.did === account.did
      ? [{ key: 'settings', href: `${base}/settings` }]
      : []),
  ];

  return (
    <nav
      aria-label="Repository sections"
      className="-mb-px flex min-w-0 touch-pan-x items-end gap-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none]"
    >
      {tabs.map((tab) => {
        const Icon = ICONS[tab.key];
        const isOpen = tab.key === open;
        return (
          <Link
            key={tab.key}
            ref={isOpen ? opened : undefined}
            href={tab.href}
            aria-current={isOpen ? 'page' : undefined}
            className={cn(
              'flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-2.5 pt-2 pb-2.5 text-[13.5px] no-underline transition-colors hover:no-underline',
              isOpen
                ? 'border-key font-medium text-foreground'
                : 'border-transparent text-muted-foreground hover:text-foreground',
            )}
          >
            <Icon
              className={cn(
                'size-3.5',
                isOpen ? 'text-foreground' : 'text-faint',
              )}
            />
            {SECTION_TITLES[tab.key]}
            {tab.pending && <Skeleton className="h-4 w-5 rounded-full" />}
            {typeof tab.count === 'number' && tab.count > 0 && (
              <Count value={tab.count} />
            )}
          </Link>
        );
      })}
    </nav>
  );
}