chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQueryClient } from '@tanstack/react-query';
import {
GitMergeIcon,
GitPullRequestClosedIcon,
GitPullRequestDraftIcon,
GitPullRequestIcon,
PencilIcon,
} from 'lucide-react';
import { useState } from 'react';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Input } from '#/components/atoms/input.jsx';
import { Handle } from '#/components/molecules/handle.jsx';
import { PageHead } from '#/components/molecules/page-head.jsx';
import { publishPull } from '#/lib/publish.js';
import { useSession } from '#/lib/session.jsx';
import { cn } from '#/lib/utils.js';
/**
* How each state reads. A forge puts this first because it is the one thing a
* reader wants before anything else.
*
* A forge tells them apart by hue; this theme reads by weight, so the icon and
* the word carry the difference and the fill only says how settled the state
* is. Withdrawn is the exception, because it is the one state a reader should
* not mistake for any other, and destructive is the one hue this palette
* keeps.
*/
const STATES = {
open: {
label: 'Open',
Icon: GitPullRequestIcon,
className: 'bg-success text-background',
},
merged: {
label: 'Merged',
Icon: GitMergeIcon,
className: 'bg-key text-background',
},
draft: {
label: 'Draft',
Icon: GitPullRequestDraftIcon,
className: 'border border-input text-muted-foreground',
},
withdrawn: {
label: 'Withdrawn',
Icon: GitPullRequestClosedIcon,
className: 'bg-destructive text-background',
},
};
/** The words the author may put on their own branch. */
const STATUSES = [
{ status: '', label: 'Open' },
{ status: 'draft', label: 'Draft' },
{ status: 'withdrawn', label: 'Withdrawn' },
];
/**
* What a pull request is, in the order a reader asks: what it is called, what
* state it is in, and who is proposing what to where.
*
* The sentence carries the facts a forge scatters across a sidebar. It is
* derived rather than stored, so each part of it is a fact about now: the
* count is the commits main lacks, and the two branch names are the ones the
* refs carry.
*
* The title and the state are the two things the author writes rather than the
* graph derives, so the author edits them here. The description is the
* conversation's opening word and edits with the conversation. The record
* holds all three, so a title edit carries the note the author last wrote.
*/
export function PullHeader({ repo, pull, defaultBranch, wide = false }) {
const { session } = useSession();
const queryClient = useQueryClient();
const mine = session?.did === pull.author.did;
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(pull.title ?? '');
const [status, setStatus] = useState(pull.status ?? '');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const state =
STATES[
pull.merged
? 'merged'
: pull.status === 'draft' || pull.status === 'withdrawn'
? pull.status
: 'open'
];
const verb = pull.merged ? 'merged' : 'wants to merge';
const edit = () => {
setTitle(pull.title ?? '');
setStatus(pull.status ?? '');
setError('');
setEditing(true);
};
const save = async (event) => {
event.preventDefault();
setBusy(true);
setError('');
try {
await publishPull(session, {
repo,
ref: `refs/heads/${pull.branch}`,
sha: pull.sha,
title: title.trim(),
status,
note: pull.note ?? '',
});
setEditing(false);
// The list holds every author's words; the next look at it re-reads.
queryClient.invalidateQueries({ queryKey: ['pulls', repo] });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (editing) {
return (
<div className="shrink-0 px-(--gutter) pt-4 pb-3.5">
<form onSubmit={save} className="flex flex-col gap-2.5">
<Input
autoFocus
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={pull.title}
aria-label="Title"
maxLength={512}
className="h-10 font-semibold text-[15px]"
disabled={busy}
/>
<div className="flex flex-wrap items-center gap-1.5">
{STATUSES.map((option) => (
<label
key={option.label}
className={`flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[12.5px] ${
status === option.status
? 'border-key/40 bg-key/10 text-key'
: 'border-input text-muted-foreground hover:text-foreground'
}`}
>
<input
type="radio"
name="status"
value={option.status}
checked={status === option.status}
onChange={() => setStatus(option.status)}
className="sr-only"
/>
{option.label}
</label>
))}
</div>
<div className="flex items-center gap-3">
<Button type="submit" size="sm" disabled={busy}>
Save
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditing(false)}
>
Cancel
</Button>
{error && (
<span className="text-[12.5px] text-destructive">{error}</span>
)}
</div>
</form>
</div>
);
}
return (
<PageHead
wide={wide}
title={pull.title}
actions={
mine && (
<Button variant="ghost" size="sm" onClick={edit}>
<PencilIcon />
Edit
</Button>
)
}
>
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1.5 text-[13px] text-muted-foreground">
<span
className={cn(
'flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 font-medium text-[12.5px]',
state.className,
)}
>
<state.Icon className="size-3.5" />
{state.label}
</span>
<Handle who={pull.author} className="text-[13px]" />
<span>
{verb}{' '}
<span className="text-foreground">
{pull.ahead} {pull.ahead === 1 ? 'commit' : 'commits'}
</span>{' '}
into
</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px] text-foreground">
{defaultBranch}
</code>
<span>from</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px] text-foreground">
{pull.branch}
</code>
{/* What the sentence above cannot say, because each is a fact about
this one rather than about every pull request. */}
{pull.behind > 0 && (
<Badge title="Main has moved since this branched.">
{pull.behind} behind
</Badge>
)}
{pull.fork && (
<Badge title="From an account this repository has not named.">
fork
</Badge>
)}
</div>
</PageHead>
);
}