chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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>
);
}