import { useQuery } from '@tanstack/react-query'; import { CheckIcon, ChevronRightIcon, CircleDotIcon, CopyIcon, FileIcon, FolderIcon, GitCommitHorizontalIcon, HistoryIcon, LockIcon, RotateCwIcon, SettingsIcon, XIcon, } from 'lucide-react'; import { useState } from 'react'; import { Button } from '#/components/atoms/button.jsx'; import { Link } from '#/components/atoms/link.jsx'; import { Notice } from '#/components/atoms/notice.jsx'; import { ListRow } from '#/components/molecules/list-row.jsx'; import { SelectField } from '#/components/molecules/select-field.jsx'; import { TextField } from '#/components/molecules/text-field.jsx'; import { CommitLogSkeleton } from '#/components/organisms/commit-log-skeleton.jsx'; import { FileContentSkeleton } from '#/components/organisms/file-content-skeleton.jsx'; import { FileTreeSkeleton } from '#/components/organisms/file-tree-skeleton.jsx'; import { RepoPageSkeleton } from '#/components/organisms/repo-page-skeleton.jsx'; import { get, post, xrpcGet, xrpcPost } from '#/lib/api.js'; import { bytes, timeAgo } from '#/lib/format.js'; import { queryKeys, useApiMutation } from '#/lib/query.js'; import { SetupInstructions } from '#/pages/git-new-repo.jsx'; /** "Name " without the email; the ident itself as the fallback. */ function authorName(ident) { const angle = ident.indexOf(' <'); return angle === -1 ? ident : ident.slice(0, angle); } /** * The commit history at a ref, read from the bundle chain's commit objects. */ function LogView({ name, refName, space }) { const { data, error } = useQuery({ queryKey: queryKeys.gitLog({ repo: name, ref: refName, space }), queryFn: () => xrpcGet('dev.pdsjs.git.listCommits', { repo: name, ref: refName, space }), }); // A server without the file browser does not serve the method at all. if (error?.status === 501) { return ( File browsing is not available on this server build, so neither is the history. ); } if (error) return {error.message}; if (!data) return ; return (
{data.commits.map((commit) => ( } title={ {commit.message.split('\n')[0]} } description={`${authorName(commit.author)} ยท ${timeAgo(new Date(commit.committedAt).toISOString())}`} actions={ {commit.sha.slice(0, 7)} } /> ))} {data.truncated && (
Showing the most recent {data.commits.length} commits.
)}
); } /** How each check status reads, and the colour it reads in. */ const CHECK_LOOKS = { success: { label: 'passed', className: 'text-success', Icon: CheckIcon }, failure: { label: 'failed', className: 'text-destructive', Icon: XIcon }, error: { label: 'errored', className: 'text-destructive', Icon: XIcon }, running: { label: 'running', className: 'text-muted-foreground', Icon: CircleDotIcon, }, }; /** * The latest check on the ref being viewed, and the button that asks for * another. Checks are published by another account, so an absent one means the * runner has not reported yet. That is not an error worth showing, but the * card still appears, because a repository with a runner and no check is * exactly the one someone wants to start a run on. * * A repository in a space has no public clone URL, so the shipped runner * cannot check it and the button is absent there. */ function CheckStatus({ name, space, refName }) { const [asked, setAsked] = useState(false); const { data } = useQuery({ queryKey: queryKeys.gitChecks({ repo: name, space }), queryFn: () => xrpcGet('dev.pdsjs.git.listChecks', { repo: name, space }), }); const action = useApiMutation([queryKeys.gitChecks({ repo: name, space })]); if (!data?.runner) return null; const checks = (data.checks ?? []) .filter((entry) => entry.ref === `refs/heads/${refName}`) .sort((a, b) => (a.workflow ?? 'ci').localeCompare(b.workflow ?? 'ci')); const rerun = () => action.run(async () => { await xrpcPost('dev.pdsjs.git.requestCheck', { name, ref: `refs/heads/${refName}`, }); setAsked(true); }); return (
{(checks.length ? checks : [null]).map((check, index) => { const look = check ? (CHECK_LOOKS[check.status] ?? CHECK_LOOKS.running) : null; return (
{look ? ( <> {check.workflow ?? 'ci'} {look.label} {check.sha?.slice(0, 8)} {check.finishedAt && ( {timeAgo(check.finishedAt)} )} ) : ( No check on this branch yet. )} {!space && index === 0 && ( {asked && !action.busy && ( Asked. The runner picks this up from the firehose. )} )}
); })} {action.error && {action.error}}
); } /** What one linked package is keyed by: the pair, since names repeat. */ const packageKey = (/** @type {{registry: string, name: string}} */ entry) => `${entry.registry}:${entry.name}`; /** How each registry reads beside a package name. */ const REGISTRY_LABELS = { npm: 'npm package', oci: 'container image' }; /** * Repository settings: description and default branch on the repo record, * protected branches, the check runner and the linked packages in the config * record. */ function SettingsView({ repo, space }) { const branches = repo.refs .filter((ref) => ref.name.startsWith('refs/heads/')) .map((ref) => ref.name.slice('refs/heads/'.length)); // A protected name whose branch is gone stays listed, so the protection is // not silently dropped by saving an unrelated change. const knownBranches = [ ...new Set([...branches, ...(repo.protectedBranches ?? [])]), ].sort(); const [description, setDescription] = useState(repo.description ?? ''); const [defaultBranch, setDefaultBranch] = useState(repo.defaultBranch ?? ''); const [protectedBranches, setProtectedBranches] = useState( new Set(repo.protectedBranches ?? []), ); const [runner, setRunner] = useState(repo.runner ?? ''); const [saved, setSaved] = useState(false); const action = useApiMutation([queryKeys.gitRepos]); // What this account has published, which is what a link may point at. Both // reads answer `enabled: false` where the registry is switched off. const { data: npm } = useQuery({ queryKey: queryKeys.npmPackages, queryFn: () => get('/npm/packages'), }); const { data: oci } = useQuery({ queryKey: queryKeys.ociRepos, queryFn: () => get('/oci/repos'), }); const linked = repo.packages ?? []; const published = [ ...(npm?.packages ?? []).map((pkg) => ({ registry: 'npm', name: pkg.name, })), ...(oci?.repos ?? []).map((image) => ({ registry: 'oci', name: image.name, })), ]; // A linked package that is no longer published stays listed, so the link is // not silently dropped by saving an unrelated change. const choices = [ ...published, ...linked.filter( (entry) => !published.some((option) => packageKey(option) === packageKey(entry)), ), ].sort((a, b) => packageKey(a).localeCompare(packageKey(b))); const [packages, setPackages] = useState(new Set(linked.map(packageKey))); const togglePackage = (/** @type {string} */ key) => { setSaved(false); setPackages((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const toggle = (/** @type {string} */ branch) => { setSaved(false); setProtectedBranches((current) => { const next = new Set(current); if (next.has(branch)) next.delete(branch); else next.add(branch); return next; }); }; const save = async (/** @type {{preventDefault: () => void}} */ event) => { event.preventDefault(); await action.run(async () => { await xrpcPost('dev.pdsjs.git.updateSettings', { name: repo.name, ...(space ? { space } : {}), description, defaultBranch, protectedBranches: [...protectedBranches], runner: runner.trim() || null, packages: choices.filter((entry) => packages.has(packageKey(entry))), }); setSaved(true); }); }; return (
{ setSaved(false); setDescription(event.target.value); }} /> { setSaved(false); setDefaultBranch(value); }} options={ branches.length > 0 ? branches.map((branch) => [branch, branch]) : [['', 'No branches yet']] } />
Protected branches {knownBranches.length === 0 ? (
Branches appear here after the first push.
) : (
{knownBranches.map((branch) => ( ))}
)}
A protected branch refuses force pushes and deletion. The server enforces this on your copy of the repository.
{ setSaved(false); setRunner(event.target.value); }} />
The account whose check records this repository trusts. Its results appear above the files. Leave it empty for no checks.
{(npm?.enabled || oci?.enabled || choices.length > 0) && (
Packages {choices.length === 0 ? (
Packages appear here after the first publish to your registry.
) : (
{choices.map((entry) => { const key = packageKey(entry); return ( ); })}
)}
What this repository builds. A package record names no repository, so this list is what connects the two. The git browser lists a linked package beside the repository, with its install command.
)} {action.error && {action.error}}
{saved && ( Saved. )}
); } /** A clone command with a copy button. */ function CloneCommand({ url }) { const [copied, setCopied] = useState(false); const command = `git clone ${url}`; const copy = async () => { await navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 1500); }; return (
{command}
); } /** * Who can read a private repository: the space's member list, with add and * remove. Shown only to the space's owner, whose PDS is the one that tracks * membership. */ function MembersCard({ space, ownDid }) { const [actor, setActor] = useState(''); const { data, error } = useQuery({ queryKey: queryKeys.spaceMembers(space), queryFn: () => get(`/spaces/members?space=${encodeURIComponent(space)}`), }); const action = useApiMutation([queryKeys.spaceMembers(space)]); const add = async (event) => { event.preventDefault(); const value = actor.trim(); if (!value) return; await action.run(async () => { await post('/spaces/members/add', { space, actor: value }); setActor(''); }); }; return (
Members can read every repository in this space
{error && {error.message}}
{(data?.members ?? []).map((member) => (
{member} {member === ownDid ? ' (you)' : ''} {member !== ownDid && ( )}
))}
setActor(event.target.value)} placeholder="handle or DID" aria-label="Add a member" className="h-9 min-w-0 flex-1 rounded-xl border border-input bg-transparent px-3 font-mono text-[12.5px] outline-none placeholder:text-faint focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" />
{action.error && (
{action.error}
)}
); } /** The clickable path above the listing: repo root, then each directory. */ function Breadcrumbs({ name, path, refHref, onNavigate }) { const segments = path.split('/').filter(Boolean); return (
{name} {segments.map((segment, i) => { const upTo = segments.slice(0, i + 1).join('/'); const last = i === segments.length - 1; return ( {last ? ( {segment} ) : ( {segment} )} ); })}
); } /** * A file's content: an image inline, text inline, a notice for other binary * or oversized files. */ function FileView({ name, refName, path, space }) { const query = `repo=${encodeURIComponent(name)}&ref=${encodeURIComponent(refName)}&path=${encodeURIComponent(path)}${space ? `&space=${encodeURIComponent(space)}` : ''}`; const { data, error } = useQuery({ queryKey: queryKeys.gitFile({ repo: name, ref: refName, path, space }), queryFn: () => xrpcGet('dev.pdsjs.git.getFile', { repo: name, ref: refName, path, space, }), }); if (error) return {error.message}; if (!data) return ; if (data.mediaType) { return (
{data.mediaType} {bytes(data.size)}
{path}
); } if (data.binary || data.truncated) { return (
{data.binary ? 'Binary file' : 'File too large to preview'} ( {bytes(data.size)})
); } const lines = data.text.split('\n'); return (
{lines.length} lines {bytes(data.size)}
        {data.text}
      
); } /** * One hosted repository: clone commands, a ref picker, and the file tree at * the chosen ref, with text previews. * @param {{name: string, space?: string, did?: string, refName?: string, path?: string, file?: string, view?: string, onNavigate: (href: string) => void}} props */ export function GitRepoPage({ name, space, did, refName, path = '', file, view, onNavigate, }) { const { data: repoData, error: repoError } = useQuery({ queryKey: queryKeys.gitRepos, queryFn: () => xrpcGet('dev.pdsjs.git.listRepos'), }); const repo = repoData?.repos.find( (r) => r.name === name && (r.space ?? undefined) === space, ); const activeRef = refName || repo?.defaultBranch || ''; const empty = repo ? repo.refs.length === 0 : false; const { data: tree, error: treeError } = useQuery({ queryKey: queryKeys.gitTree({ repo: name, ref: activeRef, path, space }), queryFn: () => xrpcGet('dev.pdsjs.git.getTree', { repo: name, ref: activeRef, path, space, }), enabled: Boolean(repoData) && !file && !empty, }); if (repoError) return {repoError.message}; if (!repoData) return ; if (!repo) { return No repository named "{name}".; } const base = `/account/git/${encodeURIComponent(name)}`; const href = ( /** @type {{ref?: string, path?: string, file?: string, view?: string}} */ next, ) => { const query = new URLSearchParams(); if (space) query.set('space', space); const ref = next.ref ?? activeRef; if (ref && ref !== repo.defaultBranch) query.set('ref', ref); if (next.path) query.set('path', next.path); if (next.file) query.set('file', next.file); if (next.view) query.set('view', next.view); const qs = query.toString(); return qs ? `${base}?${qs}` : base; }; const shortRef = (/** @type {string} */ full) => full.replace(/^refs\/(heads|tags)\//, ''); // A server without the file browser does not serve the method at all. const browseDisabled = treeError?.status === 501; const dirOf = (/** @type {string} */ p) => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''; return (
{repo.httpCloneUrl && }
{repo.space && repo.spaceOwned && ( )} {repo.space && !repo.spaceOwned && ( A private repository in a space managed by another account. Your copy of it is stored here; the member list lives with the space's owner. )} {empty ? (
) : (
href({ path: p })} onNavigate={onNavigate} />
{view && ( Files )} {view !== 'log' && ( History )} {view !== 'settings' && (!repo.space || repo.spaceOwned) && ( Settings )}
)} {!empty && !view && ( )} {empty ? null : view === 'settings' ? ( ) : view === 'log' ? ( ) : file ? ( ) : browseDisabled ? ( File browsing is not available on this server build. The repository is still fully clonable with the commands above. ) : treeError ? ( {treeError.message} ) : !tree ? ( ) : (
{path && ( } title={..} href={href({ path: dirOf(path) })} onNavigate={onNavigate} /> )} {tree.entries.map((entry) => { const entryPath = path ? `${path}/${entry.name}` : entry.name; const isDir = entry.type === 'dir'; return ( ) : ( ) } title={ {entry.name} } actions={ entry.size != null ? ( {bytes(entry.size)} ) : undefined } href={ isDir ? href({ path: entryPath }) : href({ path, file: entryPath }) } onNavigate={onNavigate} /> ); })} {tree.entries.length === 0 && (
Empty directory
)}
)}
); }