chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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 <email>" 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 (
<Notice>
File browsing is not available on this server build, so neither is the
history.
</Notice>
);
}
if (error) return <Notice variant="error">{error.message}</Notice>;
if (!data) return <CommitLogSkeleton />;
return (
<div className="rounded-2xl border border-border bg-card px-2 py-1">
{data.commits.map((commit) => (
<ListRow
key={commit.sha}
variant="plain"
media={<GitCommitHorizontalIcon className="size-4 text-faint" />}
title={
<span className="text-[13.5px]">
{commit.message.split('\n')[0]}
</span>
}
description={`${authorName(commit.author)} · ${timeAgo(new Date(commit.committedAt).toISOString())}`}
actions={
<span className="shrink-0 font-mono text-[12px] text-muted-foreground">
{commit.sha.slice(0, 7)}
</span>
}
/>
))}
{data.truncated && (
<div className="px-3 py-3 text-center text-[12px] text-faint">
Showing the most recent {data.commits.length} commits.
</div>
)}
</div>
);
}
/** 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 (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card px-5 py-3">
{(checks.length ? checks : [null]).map((check, index) => {
const look = check
? (CHECK_LOOKS[check.status] ?? CHECK_LOOKS.running)
: null;
return (
<div
key={check?.workflow ?? 'none'}
className="flex items-center gap-2 text-[13px]"
>
{look ? (
<>
<look.Icon className={`size-3.5 shrink-0 ${look.className}`} />
<span className={look.className}>
{check.workflow ?? 'ci'} {look.label}
</span>
<span className="min-w-0 truncate font-mono text-[12px] text-faint">
{check.sha?.slice(0, 8)}
</span>
{check.finishedAt && (
<span className="text-[12px] text-muted-foreground">
{timeAgo(check.finishedAt)}
</span>
)}
</>
) : (
<span className="text-[13px] text-muted-foreground">
No check on this branch yet.
</span>
)}
{!space && index === 0 && (
<span className="ml-auto flex shrink-0 items-center gap-2">
{asked && !action.busy && (
<span className="text-[12px] text-muted-foreground">
Asked. The runner picks this up from the firehose.
</span>
)}
<Button
variant="outline"
size="xs"
disabled={action.busy}
onClick={rerun}
>
<RotateCwIcon />
Run again
</Button>
</span>
)}
</div>
);
})}
{action.error && <Notice variant="error">{action.error}</Notice>}
</div>
);
}
/** 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 (
<form
onSubmit={save}
className="flex flex-col gap-4 rounded-2xl border border-border bg-card px-5 py-4"
>
<TextField
name="description"
label="Description"
placeholder="What this repository is"
value={description}
onChange={(/** @type {{target: {value: string}}} */ event) => {
setSaved(false);
setDescription(event.target.value);
}}
/>
<SelectField
name="defaultBranch"
label="Default branch"
value={defaultBranch}
onChange={(/** @type {string} */ value) => {
setSaved(false);
setDefaultBranch(value);
}}
options={
branches.length > 0
? branches.map((branch) => [branch, branch])
: [['', 'No branches yet']]
}
/>
<div className="flex flex-col gap-1.5">
<span className="text-[13px] text-muted-foreground">
Protected branches
</span>
{knownBranches.length === 0 ? (
<div className="text-[12px] text-faint">
Branches appear here after the first push.
</div>
) : (
<div className="flex flex-col gap-1">
{knownBranches.map((branch) => (
<label
key={branch}
className="flex items-center gap-2 font-mono text-[13px]"
>
<input
type="checkbox"
checked={protectedBranches.has(branch)}
onChange={() => toggle(branch)}
className="size-3.5 accent-primary"
/>
{branch}
{!branches.includes(branch) && (
<span className="font-sans text-[11px] text-faint">
(no branch by this name right now)
</span>
)}
</label>
))}
</div>
)}
<div className="text-[12px] text-faint">
A protected branch refuses force pushes and deletion. The server
enforces this on your copy of the repository.
</div>
</div>
<div className="flex flex-col gap-1.5">
<TextField
name="runner"
label="Check runner"
placeholder="did:plc:..."
value={runner}
onChange={(/** @type {{target: {value: string}}} */ event) => {
setSaved(false);
setRunner(event.target.value);
}}
/>
<div className="text-[12px] text-faint">
The account whose check records this repository trusts. Its results
appear above the files. Leave it empty for no checks.
</div>
</div>
{(npm?.enabled || oci?.enabled || choices.length > 0) && (
<div className="flex flex-col gap-1.5">
<span className="text-[13px] text-muted-foreground">Packages</span>
{choices.length === 0 ? (
<div className="text-[12px] text-faint">
Packages appear here after the first publish to your registry.
</div>
) : (
<div className="flex flex-col gap-1">
{choices.map((entry) => {
const key = packageKey(entry);
return (
<label
key={key}
className="flex items-center gap-2 font-mono text-[13px]"
>
<input
type="checkbox"
checked={packages.has(key)}
onChange={() => togglePackage(key)}
className="size-3.5 accent-primary"
/>
{entry.name}
<span className="font-sans text-[11px] text-faint">
{REGISTRY_LABELS[entry.registry] ?? entry.registry}
</span>
</label>
);
})}
</div>
)}
<div className="text-[12px] text-faint">
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.
</div>
</div>
)}
{action.error && <Notice variant="error">{action.error}</Notice>}
<div className="flex items-center gap-3">
<Button type="submit" disabled={action.busy}>
Save settings
</Button>
{saved && (
<span className="text-[12px] text-muted-foreground">Saved.</span>
)}
</div>
</form>
);
}
/** 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 (
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-background px-3 py-2">
<code className="min-w-0 flex-1 truncate font-mono text-[12px]">
{command}
</code>
<button
type="button"
aria-label="Copy clone command"
onClick={copy}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<CheckIcon className="size-3.5" />
) : (
<CopyIcon className="size-3.5" />
)}
</button>
</div>
);
}
/**
* 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 (
<div className="rounded-2xl border border-border bg-card px-5 py-4">
<div className="mb-3 flex items-center gap-2">
<LockIcon className="size-3.5 text-faint" />
<span className="font-semibold text-[14px]">Members</span>
<span className="text-[12px] text-muted-foreground">
can read every repository in this space
</span>
</div>
{error && <Notice variant="error">{error.message}</Notice>}
<div className="flex flex-col gap-1">
{(data?.members ?? []).map((member) => (
<div
key={member}
className="flex items-center justify-between gap-2 py-1"
>
<span className="min-w-0 truncate font-mono text-[12.5px]">
{member}
{member === ownDid ? ' (you)' : ''}
</span>
{member !== ownDid && (
<Button
variant="ghost"
size="xs"
aria-label={`Remove ${member}`}
disabled={action.busy}
onClick={() =>
action.run(() =>
post('/spaces/members/remove', { space, did: member }),
)
}
>
<XIcon />
</Button>
)}
</div>
))}
</div>
<form onSubmit={add} className="mt-3 flex items-center gap-2">
<input
value={actor}
onChange={(event) => 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"
/>
<Button type="submit" size="sm" disabled={!actor.trim() || action.busy}>
Add member
</Button>
</form>
{action.error && (
<div className="mt-2">
<Notice variant="error">{action.error}</Notice>
</div>
)}
</div>
);
}
/** The clickable path above the listing: repo root, then each directory. */
function Breadcrumbs({ name, path, refHref, onNavigate }) {
const segments = path.split('/').filter(Boolean);
return (
<div className="flex min-w-0 flex-wrap items-center gap-1 font-mono text-[13px]">
<Link
href={refHref('')}
onNavigate={onNavigate}
className="text-muted-foreground hover:text-foreground"
>
{name}
</Link>
{segments.map((segment, i) => {
const upTo = segments.slice(0, i + 1).join('/');
const last = i === segments.length - 1;
return (
<span key={upTo} className="flex items-center gap-1">
<ChevronRightIcon className="size-3 text-faint" />
{last ? (
<span className="text-foreground">{segment}</span>
) : (
<Link
href={refHref(upTo)}
onNavigate={onNavigate}
className="text-muted-foreground hover:text-foreground"
>
{segment}
</Link>
)}
</span>
);
})}
</div>
);
}
/**
* 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 <Notice variant="error">{error.message}</Notice>;
if (!data) return <FileContentSkeleton />;
if (data.mediaType) {
return (
<div className="rounded-2xl border border-border bg-card">
<div className="flex items-center justify-between border-border border-b px-5 py-2 text-[12px] text-muted-foreground">
<span>{data.mediaType}</span>
<span>{bytes(data.size)}</span>
</div>
<div className="flex justify-center px-5 py-6">
<img
src={`/xrpc/dev.pdsjs.git.getRaw?${query}`}
alt={path}
className="max-h-[70vh] max-w-full rounded-lg"
/>
</div>
</div>
);
}
if (data.binary || data.truncated) {
return (
<div className="rounded-2xl border border-border bg-card px-5 py-8 text-center text-[13px] text-muted-foreground">
{data.binary ? 'Binary file' : 'File too large to preview'} (
{bytes(data.size)})
</div>
);
}
const lines = data.text.split('\n');
return (
<div className="overflow-x-auto rounded-2xl border border-border bg-card">
<div className="flex items-center justify-between border-border border-b px-5 py-2 text-[12px] text-muted-foreground">
<span>{lines.length} lines</span>
<span>{bytes(data.size)}</span>
</div>
<pre className="overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-relaxed">
{data.text}
</pre>
</div>
);
}
/**
* 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 <Notice variant="error">{repoError.message}</Notice>;
if (!repoData) return <RepoPageSkeleton />;
if (!repo) {
return <Notice variant="error">No repository named "{name}".</Notice>;
}
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 (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<CloneCommand url={repo.cloneUrl} />
{repo.httpCloneUrl && <CloneCommand url={repo.httpCloneUrl} />}
</div>
{repo.space && repo.spaceOwned && (
<MembersCard space={repo.space} ownDid={did} />
)}
{repo.space && !repo.spaceOwned && (
<Notice>
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.
</Notice>
)}
{empty ? (
<div className="rounded-2xl border border-border bg-card px-5 py-4">
<SetupInstructions
name={name}
cloneUrl={repo.cloneUrl}
authority={repo.cloneUrl.split('/')[2] ?? ''}
isPrivate={Boolean(repo.space)}
/>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<Breadcrumbs
name={name}
path={file ?? path}
refHref={(/** @type {string} */ p) => href({ path: p })}
onNavigate={onNavigate}
/>
<div className="flex shrink-0 items-center gap-3">
{view && (
<Link
href={href({ path: '' })}
onNavigate={onNavigate}
className="flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
>
<FolderIcon className="size-3.5" /> Files
</Link>
)}
{view !== 'log' && (
<Link
href={href({ view: 'log' })}
onNavigate={onNavigate}
className="flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
>
<HistoryIcon className="size-3.5" /> History
</Link>
)}
{view !== 'settings' && (!repo.space || repo.spaceOwned) && (
<Link
href={href({ view: 'settings' })}
onNavigate={onNavigate}
className="flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground"
>
<SettingsIcon className="size-3.5" /> Settings
</Link>
)}
<select
aria-label="Ref"
value={activeRef}
onChange={(event) =>
onNavigate(href({ ref: event.target.value, path: '', view }))
}
className="rounded-lg border border-border bg-background px-2 py-1 font-mono text-[12px]"
>
{repo.refs.map((ref) => (
<option key={ref.name} value={shortRef(ref.name)}>
{shortRef(ref.name)}
</option>
))}
</select>
</div>
</div>
)}
{!empty && !view && (
<CheckStatus name={name} space={space} refName={activeRef} />
)}
{empty ? null : view === 'settings' ? (
<SettingsView
key={`${repo.name}#${repo.space ?? ''}`}
repo={repo}
space={space}
/>
) : view === 'log' ? (
<LogView name={name} refName={activeRef} space={space} />
) : file ? (
<FileView name={name} refName={activeRef} path={file} space={space} />
) : browseDisabled ? (
<Notice>
File browsing is not available on this server build. The repository is
still fully clonable with the commands above.
</Notice>
) : treeError ? (
<Notice variant="error">{treeError.message}</Notice>
) : !tree ? (
<FileTreeSkeleton />
) : (
<div className="rounded-2xl border border-border bg-card px-2 py-1">
{path && (
<ListRow
variant="plain"
media={<FolderIcon className="size-4 text-faint" />}
title={<span className="font-mono text-[13px]">..</span>}
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 (
<ListRow
key={entry.name}
variant="plain"
media={
isDir ? (
<FolderIcon className="size-4 text-faint" />
) : (
<FileIcon className="size-4 text-faint" />
)
}
title={
<span className="font-mono text-[13px] font-normal">
{entry.name}
</span>
}
actions={
entry.size != null ? (
<span className="shrink-0 text-[12px] text-muted-foreground">
{bytes(entry.size)}
</span>
) : undefined
}
href={
isDir
? href({ path: entryPath })
: href({ path, file: entryPath })
}
onNavigate={onNavigate}
/>
);
})}
{tree.entries.length === 0 && (
<div className="px-3 py-6 text-center text-[13px] text-muted-foreground">
Empty directory
</div>
)}
</div>
)}
</div>
);
}