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 ( ); }