~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
233 lines · 7.3 KB Raw History
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>
  );
}