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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
637 lines · 23.9 KB Raw History
import { Popover } from '@base-ui/react/popover';
import { useQuery } from '@tanstack/react-query';
import { ChevronDownIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buttonVariants } from '#/components/atoms/button.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { AuthorFacepile } from '#/components/molecules/author-facepile.jsx';
import { CommitCheckLine } from '#/components/molecules/commit-check-line.jsx';
import { DiffFile } from '#/components/molecules/diff-file.jsx';
import { DiffStat } from '#/components/molecules/diff-stat.jsx';
import { DiffTree } from '#/components/molecules/diff-tree.jsx';
import { Handle } from '#/components/molecules/handle.jsx';
import { MergeBox } from '#/components/molecules/merge-box.jsx';
import { PageBar } from '#/components/molecules/page-bar.jsx';
import { PageHead } from '#/components/molecules/page-head.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { PullChecks } from '#/components/molecules/pull-checks.jsx';
import { PullHeader } from '#/components/molecules/pull-header.jsx';
import { PullNoteEdit } from '#/components/molecules/pull-note-edit.jsx';
import { ReadingRepo } from '#/components/molecules/reading-repo.jsx';
import { ReviewComposer } from '#/components/molecules/review-composer.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { TabBar } from '#/components/molecules/tab-bar.jsx';
import { Timeline } from '#/components/molecules/timeline.jsx';
import { loadAuthors } from '#/lib/authors.js';
import { latestPerWorkflow } from '#/lib/checks.js';
import {
  diffHref,
  PULL_COMMIT_LIMIT,
  pullCommits,
  pullStatements,
  replacedTips,
} from '#/lib/collab.js';
import {
  anchorKey,
  lineKey,
  placeComments,
  threadSummaries,
} from '#/lib/comments.js';
import {
  defaultRef,
  isEmpty,
  readChange,
  repoRecord,
  repoSize,
} from '#/lib/git.js';
import { COLUMN } from '#/lib/layout.js';
import {
  extendSelection,
  lineFragment,
  lineId,
  parseLineFragment,
} from '#/lib/line-links.js';
import { useHash } from '#/lib/navigation.jsx';
import { publishReview } from '#/lib/publish.js';
import { useSession } from '#/lib/session.jsx';
import { cn } from '#/lib/utils.js';

/** One shared empty list, so a pane with nothing to show is stable. */
const none = [];

/** How many lines either side of an anchored comment read with it. */
const CONTEXT_LINES = 3;

/**
 * One commit, or everything between two of them, from this account's
 * repository or from a collaborator's copy of it.
 *
 * A commit on somebody else's branch exists only in their bundles on their
 * server, so `from` names the account to read it from. A `base..tip` address
 * reads the whole range: a pull request written over a dozen commits is one
 * thing to read, and reading it a commit at a time shows a file once per
 * commit that touched it.
 */
export function CommitPage({
  repo,
  sha,
  from,
  pull,
  checks,
  reviews,
  tab,
  backTo,
  space,
}) {
  const record = repoRecord(repo, space);
  /** Each file's section, so the tree can move to one. */
  const sections = useRef(new Map());
  const [picked, setPicked] = useState('');
  const [base, tip] = sha.includes('..') ? sha.split('..') : [null, sha];

  const { data, error } = useQuery({
    queryKey: ['commit', repo, sha, from ?? '', space ?? ''],
    enabled: Boolean(record) && !isEmpty(record),
    queryFn: () => readChange(repo, sha, from, space),
  });
  // The heading waits for the authors, so a face never lands after it.
  const authors = useQuery({
    queryKey: ['authors'],
    queryFn: loadAuthors,
    staleTime: Number.POSITIVE_INFINITY,
  });
  const shown = data && (authors.isSuccess || authors.isError);

  const pick = useCallback((path) => {
    setPicked(path);
    sections.current.get(path)?.scrollIntoView({
      behavior: 'smooth',
      block: 'start',
    });
  }, []);

  const reviewable = Boolean(base || from);
  const [reviewOpen, setReviewOpen] = useState(false);
  // A pull request opens on what has been said about it, the way a forge opens
  // a proposal. A commit that is nobody's proposal has only its diff.
  const showFiles = !pull || tab === 'files';
  const showChecks = Boolean(pull) && tab === 'checks';

  // The pull request's own commits, for the timeline. This is the read the
  // commit listing makes, under the key it makes it under, so the two share
  // it.
  const { data: log, isLoading: logLoading } = useQuery({
    queryKey: ['commits', repo, '', sha, from ?? '', space ?? ''],
    enabled: Boolean(record) && Boolean(pull) && !showFiles,
    queryFn: () => pullCommits(repo, base, tip, from, PULL_COMMIT_LIMIT),
  });

  const { session } = useSession();

  // The listed reviewers' statements, placed onto this version's lines. The
  // visitor's own, published from this page, join in from local state: a
  // reader always sees their own words, listed or not.
  const { data: statements, isLoading: statementsLoading } = useQuery({
    queryKey: ['statements', repo, space ?? ''],
    enabled: Boolean(record) && reviewable,
    queryFn: () => pullStatements(repo, space),
  });
  const [mine, setMine] = useState(
    /** @type {Array<Record<string, *>>} */ ([]),
  );
  // What a review written here names: the pull request in view, or the copy a
  // lone commit was read from. A commit outside any branch is named by its sha
  // alone.
  const author = pull ? pull.author.did : (from ?? null);
  const ref = pull ? `refs/heads/${pull.branch}` : null;
  const all = useMemo(() => {
    const listed = statements ?? [];
    const keyOf = (s) => `${s.by?.did}:${s.reviewedAt}`;
    const seen = new Set(listed.map(keyOf));
    return [...listed, ...mine.filter((s) => !seen.has(keyOf(s)))];
  }, [statements, mine]);
  const comments = useMemo(
    () => placeComments(all, { sha: tip, author: pull ? author : null, ref }),
    [all, tip, pull, author, ref],
  );
  // The tips this branch has replaced, so a statement answering one reads as
  // outdated rather than as lost.
  const replaced = useMemo(
    () =>
      pull
        ? replacedTips(pull, [...(checks ?? []), ...(reviews ?? [])])
        : new Set(),
    [pull, checks, reviews],
  );
  // What the checks tab holds: the runs against the commit this branch ends
  // on, one per workflow, which is what the tab draws and so what its count
  // has to say.
  const tipChecks = useMemo(
    () => latestPerWorkflow((checks ?? []).filter((run) => run.sha === tip)),
    [checks, tip],
  );
  const conversation = useMemo(
    () =>
      pull
        ? all.filter(
            (s) =>
              (s.author === pull.author.did &&
                s.ref?.replace(/^refs\/heads\//, '') === pull.branch) ||
              s.sha === pull.sha,
          )
        : [],
    [pull, all],
  );

  // The lines the address names, so a link to a line marks it for whoever
  // opens it and the address always says what is marked.
  const [hash, setHash] = useHash();
  const selection = useMemo(() => parseLineFragment(hash), [hash]);
  const selectLine = useCallback(
    (path, line, shift) => {
      const side = line.newLine != null ? 'new' : 'old';
      const at = line.newLine ?? line.oldLine;
      if (at == null) return;
      setHash(
        lineFragment(extendSelection(selection, { path, side, at }, shift)),
      );
    },
    [selection, setHash],
  );

  const addStatement = useCallback((statement) => {
    setMine((held) => [...held, statement]);
  }, []);

  const onComment = useCallback(
    async ({ note, replyTo, ...anchor }) => {
      const place = anchor.path ? anchor : undefined;
      const written = await publishReview(session, {
        repo,
        sha: tip,
        author,
        ref,
        verdict: 'comment',
        note,
        anchor: place,
        replyTo,
      });
      setMine((held) => [
        ...held,
        {
          // The written record's own address, so an answer to these words
          // has something to name before any re-read.
          uri: written.uri,
          cid: written.cid,
          sha: tip,
          author,
          ref,
          verdict: 'comment',
          note,
          ...(place ? { anchor: place } : {}),
          ...(replyTo ? { replyTo } : {}),
          reviewedAt: new Date().toISOString(),
          by: { did: session.did, handle: session.handle },
        },
      ]);
    },
    [session, repo, tip, author, ref],
  );

  // A link from the pull request list names a thread in the fragment, and a
  // shared link names a line. The diff and its comments arrive after the first
  // paint, so the browser's own fragment scroll finds nothing; this one waits
  // for them. The address is read from the window rather than from the hook
  // above, so a click in the gutter moves nothing under the reader.
  useEffect(() => {
    if (!data || !shown || !showFiles) return;
    const at = decodeURIComponent(window.location.hash.slice(1));
    if (at.startsWith('comment-')) {
      if (comments.byFile.size === 0) return;
      document.getElementById(at)?.scrollIntoView({ block: 'center' });
      return;
    }
    const lines = parseLineFragment(at);
    if (!lines) return;
    document
      .getElementById(lineId(lines.path, `${lines.side}:${lines.start}`))
      ?.scrollIntoView({ block: 'center' });
  }, [data, shown, comments, showFiles]);

  if (!record) {
    return <PageNotice>No such repository.</PageNotice>;
  }

  // An empty repository holds no commits, so no sha names one. Say so rather
  // than asking the server for a commit that cannot exist.
  if (isEmpty(record)) {
    return <PageNotice>No commits yet.</PageNotice>;
  }

  if (error) {
    return <PageNotice tone="error">{error.message}</PageNotice>;
  }
  if (!shown) {
    return (
      <div className="min-h-0 flex-1 overflow-y-auto">
        <div className={cn(pull && COLUMN)}>
          <div className="flex flex-col gap-3 px-(--gutter) pt-4 pb-5">
            <Skeleton className="h-6 w-1/2 max-w-md" />
            <Skeleton className="h-4 w-2/3 max-w-lg" />
          </div>
          <div className="empty:hidden px-(--gutter) pb-4">
            <ReadingRepo size={repoSize(record)} />
          </div>
          <div className="flex flex-col gap-2.5 border-border border-t px-(--gutter) py-4">
            <Skeleton className="h-4 w-1/3" />
            <Skeleton className="h-4 w-3/4" />
            <Skeleton className="h-4 w-2/3" />
            <Skeleton className="h-4 w-1/2" />
          </div>
        </div>
      </div>
    );
  }

  const [subject, ...rest] = data.message.split('\n');
  const body = rest.join('\n').trim();
  // One function owns the shape of a pull request's address, so the divider
  // between the branch and what follows it is written in one place.
  const at = pull ? diffHref(repo, pull) : '';
  const tabHref = (name) => (pull ? diffHref(repo, pull, name) : '');
  const threads = threadSummaries(conversation).length;
  const whose = from ? `/${encodeURIComponent(from)}` : '';
  const commitHref = (one) =>
    pull
      ? diffHref(repo, pull, `commit/${one}`)
      : `/${encodeURIComponent(repo)}/commit${whose}/${one}`;
  const commitsAt = `/${encodeURIComponent(repo)}/commits${whose}/${sha}`;

  // A few lines either side of an anchored comment, out of the diff already
  // in hand. Only a comment on the version in view has any: one written
  // against a tip the branch has replaced is placed nowhere in this diff, and
  // the line it stored is all that is left of it.
  const contextFor = (anchor) => {
    const file = data.files.find((one) => one.path === anchor.path);
    if (!file?.hunks) return null;
    const rows = file.hunks.flatMap((hunk) => hunk.lines);
    // The span the comment covers: its first line to its last. A single-line
    // comment has the two the same. anchorKey keys the last line, and the
    // first is the anchor's own side and line.
    const side = anchor.side === 'old' ? 'old' : 'new';
    const startKey = `${side}:${anchor.line}`;
    const startAt = rows.findIndex((row) => lineKey(row) === startKey);
    const endAt = rows.findIndex((row) => lineKey(row) === anchorKey(anchor));
    if (startAt < 0 || endAt < 0) return null;
    const [lo, hi] = startAt <= endAt ? [startAt, endAt] : [endAt, startAt];
    const from = Math.max(0, lo - CONTEXT_LINES);
    return {
      lines: rows.slice(from, hi + CONTEXT_LINES + 1),
      from: lo - from,
      to: hi - from,
    };
  };

  // A thread anchored to a line is a place in the diff, which is the other
  // tab. The address carries both, so the fragment is still there when the
  // diff has rendered and the effect above can find it.
  const threadHref = (fragment) =>
    `${tabHref('files')}#${encodeURIComponent(fragment)}`;

  // The head of the page: what the pull request is, and the tabs over it. It
  // is pinned on every tab, because it holds nothing that grows: what the
  // author wrote is the first word of the conversation and reads there, under
  // the tabs rather than over them.
  const head = (
    <>
      {/* A pull request opens on what it is, in the order a reader asks;
          a plain commit opens on the subject and who wrote it, the way the
          ref and the path do on the source view. */}
      {pull ? (
        <PullHeader
          wide={showFiles}
          repo={repo}
          pull={pull}
          defaultBranch={defaultRef(record)}
        />
      ) : (
        <PageHead
          wide
          title={subject}
          // Where this commit was reached from: the pull request that
          // carries it, or the branch's history for one that is nobody's
          // proposal.
          back={
            backTo
              ? { href: backTo.href, label: backTo.title }
              : {
                  href: `/${encodeURIComponent(repo)}/commits/${encodeURI(defaultRef(record))}`,
                  label: 'Commits',
                }
          }
          meta={
            <>
              <AuthorFacepile ident={data.author} message={data.message} />
              <span>{new Date(data.committedAt).toLocaleString()}</span>
              <code className="break-all font-mono text-key">
                {data.base ? `${data.base.slice(0, 8)}..` : ''}
                {data.sha}
              </code>
              {data.copy && (
                <span className="flex min-w-0 items-baseline gap-1">
                  in
                  <Handle who={data.copy} className="text-faint">
                    's copy
                  </Handle>
                </span>
              )}
              <span>
                {data.files.length} {data.files.length === 1 ? 'file' : 'files'}{' '}
                changed
              </span>
              <CommitCheckLine repo={repo} sha={data.sha} space={space} />
              {/* What a bare range address is showing. A pull request has
                  said it already, in the count of commits it is ahead by. */}
              {data.base && <span>every commit since it branched</span>}
            </>
          }
        />
      )}

      {/* A pull request is read two ways, and one scroll cannot hold both: on
          one of any size the conversation scrolls away under the diffs. A
          plain commit has nothing to say for itself, so it keeps the one
          pane. */}
      {pull && (
        <PageBar
          tabs
          wide={showFiles}
          // The head scrolls away with the page and the tabs stay under the
          // bar, so a phone gives the reading the room the head took.
          className="sticky top-0 z-10 bg-background"
          // Beside the tabs, where a forge puts it: the size of the thing
          // the files tab opens, without opening it. A phone has no room
          // for it beside the tabs.
          actions={
            <span className="hidden sm:block">
              <DiffStat files={data.files} />
            </span>
          }
        >
          <TabBar
            className="border-b-0"
            active={tab === 'files' || tab === 'checks' ? tab : 'conversation'}
            tabs={[
              {
                key: 'conversation',
                label: 'Conversation',
                href: at,
                count: threads,
              },
              {
                key: 'checks',
                label: 'Checks',
                href: tabHref('checks'),
                count: tipChecks.length,
              },
              {
                key: 'files',
                label: 'Files changed',
                href: tabHref('files'),
                count: data.files.length,
              },
            ]}
          />
        </PageBar>
      )}
    </>
  );

  return (
    <div className="flex min-h-0 flex-1 flex-col">
      {showChecks ? (
        <Screen wide>
          {head}
          <div className={COLUMN}>
            <PullChecks
              repo={repo}
              checks={checks ?? none}
              tip={tip}
              space={space}
            />
          </div>
        </Screen>
      ) : showFiles ? (
        <Screen wide>
          {head}
          <div
            className={cn(
              'flex items-start',
              !pull && 'border-border border-t',
            )}
          >
            {/* The tree pins under the tabs while the diffs scroll past it,
                and steps aside on a narrow screen where there is no room
                for two columns. */}
            {data.files.length > 1 && (
              <aside
                className={cn(
                  'sticky hidden max-h-[calc(100dvh-10rem)] w-72 shrink-0 self-start overflow-y-auto border-border border-r px-2 py-3 lg:block',
                  pull ? 'top-12' : 'top-0',
                )}
              >
                <DiffTree files={data.files} onPick={pick} picked={picked} />
              </aside>
            )}
            <div className="min-w-0 flex-1">
              {/* On a pull request this reads at the top of the activity, the
                way the author wrote it before anybody answered. */}
              {!pull && body && (
                <div className="border-border border-b px-(--gutter) py-4">
                  <pre className="max-w-3xl whitespace-pre-wrap break-words font-mono text-[13px] text-muted-foreground">
                    {body}
                  </pre>
                </div>
              )}
              {/* The verdict is given here, on the files, where a forge keeps
                it: a reader has read the code it speaks to, and the
                conversation is left for words rather than judgements. A
                commit of this account's own history is a fact, not a pull
                request, but it is reviewed the same way. The composer opens
                from a button so the diffs, not the form, hold the column. */}
              {reviewable && session && (
                <div className="flex items-center justify-end border-border border-b px-(--gutter) py-2.5">
                  <Popover.Root open={reviewOpen} onOpenChange={setReviewOpen}>
                    <Popover.Trigger
                      className={cn(buttonVariants({ size: 'sm' }))}
                    >
                      Review changes
                      <ChevronDownIcon className="size-3.5" />
                    </Popover.Trigger>
                    <Popover.Portal>
                      <Popover.Positioner
                        className="z-50"
                        sideOffset={6}
                        align="end"
                      >
                        <Popover.Popup className="w-[min(34rem,calc(100vw-2rem))] rounded-2xl border border-border bg-card p-4 shadow-lg outline-none">
                          <ReviewComposer
                            repo={repo}
                            sha={data.sha}
                            author={author}
                            refName={ref}
                            variant="review"
                            bare
                            onPublished={(statement) => {
                              addStatement(statement);
                              setReviewOpen(false);
                            }}
                          />
                        </Popover.Popup>
                      </Popover.Positioner>
                    </Popover.Portal>
                  </Popover.Root>
                </div>
              )}
              {!pull && reviewable && comments.elsewhere > 0 && (
                <p className="border-border border-b px-(--gutter) py-3 text-[12.5px] text-faint">
                  {comments.elsewhere}{' '}
                  {comments.elsewhere === 1 ? 'comment' : 'comments'} on commits
                  this branch has replaced, on the conversation.
                </p>
              )}
              {data.files.map((file) => (
                <DiffFile
                  key={file.path}
                  file={file}
                  comments={comments.byFile.get(file.path)}
                  spans={comments.spans.get(file.path)}
                  onComment={reviewable && session ? onComment : undefined}
                  selection={selection}
                  onSelectLine={(line, shift) =>
                    selectLine(file.path, line, shift)
                  }
                  ref={(node) => {
                    if (node) sections.current.set(file.path, node);
                    else sections.current.delete(file.path);
                  }}
                />
              ))}
            </div>
          </div>
        </Screen>
      ) : (
        <Screen wide>
          {head}
          <div className={COLUMN}>
            {/* The author's first word, which is what a description is. It reads
              under the tabs rather than over them, so the head above stays the
              same height whichever tab is open. */}
            {pull && (pull.note || session?.did === pull.author.did) && (
              <div className="px-(--gutter) pt-4">
                <PullNoteEdit repo={repo} pull={pull} />
              </div>
            )}
            {body && (
              <div className="px-(--gutter) pt-4">
                <pre className="whitespace-pre-wrap break-words font-mono text-[13px] text-muted-foreground">
                  {body}
                </pre>
              </div>
            )}
            <div className="px-(--gutter)">
              <div>
                <Timeline
                  loading={logLoading || statementsLoading}
                  checks={checks}
                  replaced={replaced}
                  statements={conversation}
                  sha={tip}
                  contextFor={contextFor}
                  commits={log?.commits}
                  commitHref={commitHref}
                  onReply={session ? onComment : undefined}
                  threadHref={threadHref}
                />
                {log?.truncated && (
                  <p className="pb-4 text-[12.5px] text-faint">
                    Showing the newest {PULL_COMMIT_LIMIT} commits.{' '}
                    <Link href={commitsAt}>All of them</Link>.
                  </p>
                )}
              </div>
            </div>
            {/* The merge box gathers the state a reader needs before the merge:
              the checks, the conflicts, the button. It reads under the
              timeline and above the comment box, the way a forge stacks it. */}
            {pull && (
              <MergeBox
                repo={repo}
                pull={pull}
                checks={tipChecks}
                checksHref={tabHref('checks')}
              />
            )}
            {/* The comment box is the last thing, the word a reader adds after
              reading the story and the state above it. */}
            {session && (
              <div className="px-(--gutter) pt-2 pb-6">
                <div>
                  <ReviewComposer
                    repo={repo}
                    sha={data.sha}
                    author={author}
                    refName={ref}
                    variant="comment"
                    onPublished={addStatement}
                  />
                </div>
              </div>
            )}
          </div>
        </Screen>
      )}
    </div>
  );
}