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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
350 lines · 13.6 KB Raw History
import { GitCommitVerticalIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { AuthorFaces } from '#/components/molecules/author-facepile.jsx';
import { StatementCard } from '#/components/molecules/statement-card.jsx';
import { checkLook, latestBySha, summarize } from '#/lib/checks.js';
import {
  commentFragment,
  threadStack,
  threadSummaries,
} from '#/lib/comments.js';
import { ROW_STYLE, SIGN, SIGN_STYLE } from '#/lib/diff-rows.js';
import { timeAgo } from '#/lib/format.js';
import { highlightToLines, languageFor } from '#/lib/highlight.js';
import { useSession } from '#/lib/session.jsx';
import { cn } from '#/lib/utils.js';
import { verdictLook } from '#/lib/verdicts.js';

/**
 * The code an anchored comment speaks to, drawn the way the diff draws it:
 * the same sign in the gutter and the same weight behind an added or a
 * deleted line, so a quoted line looks like the line it quotes.
 *
 * Where the comment is on the commit in view the lines either side read with
 * it, and the line itself is marked. Where it is not, the anchor's own record
 * of the line is all there is, and a snippet has no side to draw.
 */
function AnchoredCode({ anchor, context }) {
  const rows = context?.lines ?? [];
  const text = context
    ? rows.map((row) => row.text).join('\n')
    : (anchor.snippet ?? '');
  if (!text) return null;
  const coloured = highlightToLines(text, languageFor(anchor.path));

  return (
    <div className="overflow-x-auto py-1 font-mono text-[12.5px] leading-6">
      {coloured.map((html, index) => {
        const row = rows[index];
        const type = row?.type ?? 'context';
        return (
          <div
            key={row ? `${row.type}-${row.oldLine}-${row.newLine}` : 'snippet'}
            className={cn(
              'flex whitespace-pre px-3',
              ROW_STYLE[type],
              // The lines the comment is about, marked over whatever side
              // they are on. A single line is a span of one.
              context &&
                index >= context.from &&
                index <= context.to &&
                'bg-key/10',
            )}
          >
            <span className="w-10 shrink-0 select-none text-right text-faint tabular-nums">
              {row ? (row.newLine ?? row.oldLine ?? '') : (anchor.line ?? '')}
            </span>
            <span className={cn('shrink-0 select-none pl-3', SIGN_STYLE[type])}>
              {SIGN[type]}
            </span>
            <span
              className={cn('text-muted-foreground', row ? 'pl-2' : 'pl-3')}
              // biome-ignore lint/security/noDangerouslySetInnerHtml: the highlighter escapes its input
              dangerouslySetInnerHTML={{ __html: html }}
            />
          </div>
        );
      })}
    </div>
  );
}

/**
 * What happened to one pull request, in the order it happened: the commits its
 * author pushed and the threads anyone opened, on one list.
 *
 * The list runs oldest first, against the newest-first the rest of the app
 * reads. This one is a story rather than a listing, and a push answered by
 * a comment only makes sense in that order.
 *
 * A thread anchored to a line is a place in the diff, which is the tab
 * beside this one, so it appears here as one line that opens there. A
 * thread about the whole pull request has no such place, so it appears in
 * full: root and replies, with a Reply under each statement that can be
 * answered. Inside a thread the conversation runs oldest first too.
 *
 * The commits are the tip's, so a rebased branch lists what it carries now.
 * A statement made against a version that was rewritten still shows, with
 * the version tag saying which tip it answered.
 *
 * A commit that was a tip when the runner saw it carries what the run said,
 * so the commit that broke the build is the one with the mark against it. One
 * that was never a tip carries nothing, because nothing ran against it: the
 * runner checks a push, and a commit arriving under a later one is checked
 * only as part of what it arrived with.
 */
export function Timeline({
  loading = false,
  checks,
  replaced,
  statements,
  commits,
  sha,
  contextFor,
  commitHref,
  onReply,
  threadHref,
}) {
  const { session } = useSession();
  /** The statement being answered, or null. */
  const [composing, setComposing] = useState(null);
  const [note, setNote] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  const threads = threadSummaries(statements);
  // The runs against each commit, so a row can say what happened to it. The
  // latest per workflow, the way a commit's own page counts them.
  const ran = latestBySha(checks ?? []);
  // One list, in the order the events landed. A thread sits where it was
  // opened, not where it was last answered. A commit dates in epoch ms and a
  // statement in an ISO string, which Date reads either way.
  //
  // A commit is placed by when it was written, not when it was committed. A
  // rebase rewrites every commit it moves and stamps them all with the
  // moment it ran, so committer dates put the commit that caused the rebase
  // before the ones it moved. Author dates survive it.
  const events = [
    ...(commits ?? []).map((commit) => ({
      key: `commit:${commit.sha}`,
      at: commit.authoredAt ?? commit.committedAt,
      commit,
    })),
    ...threads.map((thread) => ({
      key: `thread:${thread.root.by?.did}:${thread.root.reviewedAt}`,
      at: thread.root.reviewedAt,
      thread,
    })),
  ].sort((a, b) => new Date(a.at) - new Date(b.at));

  // Nothing has arrived yet is not the same as nothing was said. The story
  // holds its shape while its commits and statements are read.
  if (loading && events.length === 0) {
    return (
      <div className="flex flex-col gap-3 py-4" aria-hidden="true">
        <Skeleton className="h-4 w-1/2" />
        <Skeleton className="h-4 w-2/5" />
        <Skeleton className="h-24 w-full" />
        <Skeleton className="h-4 w-1/3" />
      </div>
    );
  }
  if (events.length === 0) {
    return (
      <p className="py-6 text-[13px] text-faint">
        Nothing pushed or said about this pull request yet.
      </p>
    );
  }

  const post = async (event) => {
    event.preventDefault();
    const target = composing;
    setBusy(true);
    setError('');
    try {
      await onReply({
        note: note.trim(),
        replyTo: { uri: target.uri, cid: target.cid },
      });
      setNote('');
      // Another statement may have been picked while this posted; only the
      // composer this post came from closes.
      setComposing((open) => (open === target ? null : open));
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setBusy(false);
    }
  };

  /** Whether a statement answers a commit the branch has since replaced. */
  const outdated = (statement) => replaced?.has(statement.sha);

  return (
    <div className="flex flex-col gap-2 py-4">
      {events.map((event) => {
        // A commit the author pushed. It opens on its own page, the way a
        // row of the commit listing does.
        if (event.commit) {
          const { commit } = event;
          const summary = summarize(ran.get(commit.sha) ?? []);
          const look = summary ? checkLook(summary.status) : null;
          return (
            <Link
              key={event.key}
              href={commitHref(commit.sha)}
              className="-mx-1.5 flex items-center gap-2 rounded-lg px-1.5 py-1 text-[12.5px] text-foreground no-underline hover:bg-accent/50 hover:no-underline"
            >
              <GitCommitVerticalIcon className="size-3.5 shrink-0 text-faint" />
              {/* Whose commit it is, as a face rather than a name: the
                  message is what the row is for, and a name beside every one
                  of a dozen commits is the same word a dozen times. */}
              <AuthorFaces ident={commit.author} message={commit.message} />
              <span className="min-w-0 truncate">
                {commit.message.split('\n')[0]}
              </span>
              <code className="shrink-0 font-mono text-[12px] text-faint">
                {commit.sha.slice(0, 8)}
              </code>
              <span className="shrink-0 text-[12px] text-faint">
                {timeAgo(commit.authoredAt ?? commit.committedAt)}
              </span>
              {look && (
                <look.Icon
                  className={cn('size-3.5 shrink-0', look.className)}
                  aria-label={`checks ${look.label}`}
                />
              )}
            </Link>
          );
        }

        const { root, thread } = event.thread;
        // A thread anchored to a line reads with the line it is about, the
        // way it would in the diff. The snippet is what the anchor recorded
        // when the comment was written, so it is the code as it stood then.
        const anchor = root.anchor?.path ? root.anchor : null;
        const fragment = anchor ? commentFragment(anchor.path, anchor) : '';
        // Only a comment on the version in view has a line to open. One
        // written against a tip the branch has since replaced is placed
        // nowhere in this diff, so a link would scroll to nothing.
        const inDiff = anchor && root.sha === sha;

        return (
          <div key={event.key} className="flex flex-col gap-2">
            {anchor && (
              <div className="overflow-hidden rounded-md border border-border">
                <div className="flex flex-wrap items-baseline gap-x-3 border-border border-b bg-muted px-3 py-1.5 text-[12px]">
                  <code className="min-w-0 truncate font-mono text-faint">
                    {anchor.path}
                    {anchor.line
                      ? anchor.endLine && anchor.endLine !== anchor.line
                        ? `:${anchor.line}-${anchor.endLine}`
                        : `:${anchor.line}`
                      : ''}
                  </code>
                  {inDiff && threadHref ? (
                    <Link href={threadHref(fragment)} className="ml-auto">
                      Open in the diff
                    </Link>
                  ) : (
                    <span
                      className="ml-auto shrink-0 rounded-full border border-input px-2 py-px text-[11.5px] text-faint"
                      title="Written against a commit this branch has since replaced, so it is placed nowhere in the diff."
                    >
                      Outdated
                    </span>
                  )}
                </div>
                <AnchoredCode
                  anchor={anchor}
                  context={inDiff ? contextFor?.(anchor) : null}
                />
              </div>
            )}
            {threadStack(thread).map((statement) => {
              const said = verdictLook(statement.verdict);
              return (
                <StatementCard
                  key={`${statement.by?.did}:${statement.reviewedAt}`}
                  who={statement.by}
                  at={statement.reviewedAt}
                  note={statement.note}
                  className={statement.depth > 0 ? 'ml-6' : ''}
                  mark={
                    statement.verdict !== 'comment' && (
                      <span
                        className={`flex items-center gap-1 ${said.className}`}
                      >
                        <said.Icon className="size-3.5" />
                        {said.label}
                        {outdated(statement) && (
                          <span className="text-faint">outdated</span>
                        )}
                      </span>
                    )
                  }
                  action={
                    onReply &&
                    session &&
                    statement.uri && (
                      <button
                        type="button"
                        onClick={() => {
                          setComposing(statement);
                          setError('');
                        }}
                        className="cursor-pointer text-[12px] text-key hover:underline"
                      >
                        Reply
                      </button>
                    )
                  }
                />
              );
            })}
            {composing && thread.some((s) => s.uri === composing.uri) && (
              <form onSubmit={post} className="ml-6 flex flex-col gap-2">
                <Textarea
                  value={note}
                  onChange={(event) => setNote(event.target.value)}
                  placeholder="Your answer."
                  aria-label="Your answer"
                  rows={2}
                  disabled={busy}
                />
                <div className="flex items-center gap-2">
                  <Button
                    type="submit"
                    size="sm"
                    disabled={busy || !note.trim()}
                  >
                    Reply
                  </Button>
                  <Button
                    type="button"
                    variant="ghost"
                    size="sm"
                    onClick={() => setComposing(null)}
                  >
                    Cancel
                  </Button>
                  {error && (
                    <span className="text-[12px] text-destructive">
                      {error}
                    </span>
                  )}
                </div>
              </form>
            )}
          </div>
        );
      })}
    </div>
  );
}