374 lines · 12.6 KB · TypeScript
RawDownload
/** * Pull-request-specific annotations: conversations already on the host and comments queued for * the review being written. New comment composition uses the shared diff annotation. */import type {  EnvironmentId,  PullRequestRef,  PullRequestReviewThread,  PullRequestThreadCommentsResult,  PullRequestThreadComment,} from "@t3tools/contracts";import {  CheckCircle2Icon,  CircleIcon,  HammerIcon,  MessageSquareIcon,  PencilIcon,  Trash2Icon,} from "lucide-react";import { useRef, useState } from "react"; import { formatRelativeTimeLabel } from "~/timestampFormat";import { cn } from "~/lib/utils"; import { Button } from "../ui/button";import { Textarea } from "../ui/textarea";import { isCommentSubmitShortcut } from "../diffs/commentSubmitShortcut";import {  editPullRequestThreadComment,  mergePullRequestThreadComments,} from "./pullRequestDetail.logic";import { PullRequestActorLabel } from "./pullRequestPresentation";import { PullRequestMarkdown } from "./PullRequestMarkdown";import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";import { PullRequestReactionBar } from "./PullRequestReactions";import type { PendingReviewComment } from "./pullRequestReviewStore"; const CARD_CLASS =  "mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 text-sm shadow-sm"; /** Sends a reply on ⌘/Ctrl+Enter and abandons it on Escape. */function submitKeys(input: {  readonly value: string;  readonly pending: boolean;  readonly onSubmit: () => void;  readonly onCancel?: (() => void) | undefined;}) {  return (event: React.KeyboardEvent<HTMLTextAreaElement>) => {    if (event.key === "Escape" && input.onCancel) {      event.preventDefault();      input.onCancel();    }    if (isCommentSubmitShortcut(event, input.value, input.pending)) {      event.preventDefault();      input.onSubmit();    }  };} /** A comment waiting to be sent with the rest of the review. */export function PendingReviewCommentCard({  comment,  onRemove,}: {  comment: PendingReviewComment;  onRemove: () => void;}) {  return (    <div      className={cn(CARD_CLASS, "border-dashed")}      contentEditable={false}      onPointerDown={(event) => event.stopPropagation()}    >      <div className="flex items-center gap-2 text-xs text-muted-foreground">        <MessageSquareIcon className="size-3.5" />        <span>Pending — sent when you submit the review</span>        <Button          size="icon-xs"          variant="ghost"          className="ml-auto"          aria-label="Discard this comment"          onClick={onRemove}        >          <Trash2Icon className="size-3.5" />        </Button>      </div>      <p className="mt-2 whitespace-pre-wrap leading-relaxed">{comment.body}</p>    </div>  );} /** A conversation already on the host, with whatever this host lets the reader do to it. */export function ReviewThreadCard({  thread,  workspaceRoot,  canReply,  canResolve,  canReact,  environmentId,  reference,  pending,  fixPending,  fixLabel = "Fix in a thread",  onFix,  onReply,  onLoadMore,  canEditComment,  onEditComment,  onToggleResolved,  onReacted,}: {  thread: PullRequestReviewThread;  workspaceRoot: string;  canReply: boolean;  canResolve: boolean;  canReact: boolean;  environmentId: EnvironmentId;  reference: PullRequestRef;  pending: boolean;  /** True while this thread's own hand-off is preparing, so only its button says so. */  fixPending?: boolean;  fixLabel?: string;  /** Absent where a thread is shown outside the pull request page's reach. */  onFix?: () => void;  /** Resolves to whether the host took it, so a reply that failed keeps the words it was given. */  onReply: (body: string) => Promise<boolean>;  /** Reads one more page only after the reader asks for it. */  onLoadMore: (cursor: string) => Promise<PullRequestThreadCommentsResult | null>;  /** Whether this reader wrote this remark, which is what rewriting one takes. */  canEditComment: (comment: PullRequestThreadComment) => boolean;  /** Resolves to whether the host took it, like `onReply`. */  onEditComment: (commentId: string, body: string) => Promise<boolean>;  onToggleResolved: () => void;  onReacted: () => void;}) {  // A resolved thread is finished work, so it opens collapsed and stays one line until asked for.  const [expanded, setExpanded] = useState(!thread.isResolved);  const [replying, setReplying] = useState(false);  const [reply, setReply] = useState("");  const [editingId, setEditingId] = useState<string | null>(null);  const [savingEdit, setSavingEdit] = useState(false);  const sendingRef = useRef(false);  const [loadedPage, setLoadedPage] = useState<    (PullRequestThreadCommentsResult & { readonly threadId: string }) | null  >(null);  const [loadingMore, setLoadingMore] = useState(false);  const currentPage = loadedPage?.threadId === thread.id ? loadedPage : null;  const comments = mergePullRequestThreadComments(thread.comments, currentPage?.comments ?? []);  const nextCommentsCursor =    currentPage === null ? (thread.nextCommentsCursor ?? null) : currentPage.nextCursor;  const commentCount = thread.commentCount ?? comments.length;   const saveEdit = async (commentId: string, body: string) => {    if (savingEdit) return;    setSavingEdit(true);    const saved = await onEditComment(commentId, body);    setSavingEdit(false);    if (saved) {      setLoadedPage((previous) =>        previous?.threadId === thread.id          ? {              ...previous,              comments: editPullRequestThreadComment(previous.comments, commentId, body),            }          : previous,      );      setEditingId(null);    }  };   const send = async () => {    const trimmed = reply.trim();    if (trimmed.length === 0 || pending || sendingRef.current) return;    sendingRef.current = true;    // Cleared only once the host has it. Otherwise a failed reply leaves an error toast and an    // empty box, and the words have to be written again.    try {      if (await onReply(trimmed)) {        // The mutation returns no comment. Keep what the reader loaded and reopen its cursor so        // the new reply remains reachable without spending requests until they ask to load it.        setLoadedPage((previous) =>          previous?.threadId === thread.id            ? {                ...previous,                nextCursor: previous.nextCursor ?? thread.nextCommentsCursor ?? null,              }            : previous,        );        setReply("");        setReplying(false);      }    } finally {      sendingRef.current = false;    }  };  const loadMore = async () => {    if (nextCommentsCursor === null || loadingMore) return;    setLoadingMore(true);    try {      const page = await onLoadMore(nextCommentsCursor);      if (page === null) return;      setLoadedPage((previous) => ({        threadId: thread.id,        comments: mergePullRequestThreadComments(          previous?.threadId === thread.id ? previous.comments : [],          page.comments,        ),        nextCursor: page.nextCursor,      }));    } finally {      setLoadingMore(false);    }  };   return (    <div      className={CARD_CLASS}      contentEditable={false}      onPointerDown={(event) => event.stopPropagation()}    >      <div className="flex items-center gap-2 text-xs text-muted-foreground">        {thread.isResolved ? (          <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500" />        ) : (          <CircleIcon className="size-3.5" />        )}        <button          type="button"          className="hover:text-foreground"          aria-expanded={expanded}          onClick={() => setExpanded((current) => !current)}        >          {thread.isResolved ? "Resolved" : "Open"} · {commentCount}{" "}          {commentCount === 1 ? "comment" : "comments"}        </button>        {thread.isOutdated ? <span>outdated</span> : null}        {onFix ? (          <Button            size="xs"            variant="ghost"            className="ml-auto"            disabled={pending || fixPending}            onClick={onFix}          >            <HammerIcon className="size-3" />            {fixPending ? "Preparing..." : fixLabel}          </Button>        ) : null}        {canResolve ? (          <Button            size="xs"            variant="ghost"            className={onFix ? undefined : "ml-auto"}            disabled={pending}            onClick={onToggleResolved}          >            {thread.isResolved ? "Unresolve" : "Resolve"}          </Button>        ) : null}      </div>       {expanded ? (        <>          <div className="mt-2 space-y-3">            {comments.map((comment) => (              <article key={comment.id} className="group min-w-0">                <div className="flex items-center gap-2 text-xs text-muted-foreground">                  <PullRequestActorLabel actor={comment.author} className="text-foreground" />                  <span>{formatRelativeTimeLabel(comment.createdAt)}</span>                </div>                {editingId === comment.id ? (                  <PullRequestMarkdownEditor                    className="mt-1"                    value={comment.body}                    cwd={workspaceRoot}                    environmentId={environmentId}                    label="Edit comment"                    saving={savingEdit}                    onSave={(body) => void saveEdit(comment.id, body)}                    onCancel={() => setEditingId(null)}                  />                ) : (                  <div className="mt-1 flex items-start gap-1">                    <PullRequestMarkdown                      className="min-w-0 flex-1 text-sm"                      text={comment.body}                      cwd={workspaceRoot}                      environmentId={environmentId}                    />                    {canEditComment(comment) ? (                      <Button                        size="icon-xs"                        variant="ghost"                        className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"                        aria-label="Edit comment"                        onClick={() => setEditingId(comment.id)}                      >                        <PencilIcon className="size-3" />                      </Button>                    ) : null}                  </div>                )}                <PullRequestReactionBar                  className="mt-1.5"                  reactions={comment.reactions ?? []}                  canReact={canReact}                  subjectId={comment.id}                  environmentId={environmentId}                  reference={reference}                  onRefresh={onReacted}                />              </article>            ))}          </div>          {nextCommentsCursor !== null ? (            <div className="mt-2">              <Button                size="xs"                variant="ghost"                className="px-1"                disabled={loadingMore}                onClick={() => void loadMore()}              >                {loadingMore ? "Loading..." : "Load more comments"}              </Button>            </div>          ) : null}           {canReply ? (            replying ? (              <div className="mt-2">                <Textarea                  autoFocus                  size="sm"                  value={reply}                  placeholder="Reply"                  aria-label="Reply to this conversation"                  onChange={(event) => setReply(event.target.value)}                  onKeyDown={submitKeys({                    value: reply,                    pending,                    onSubmit: () => void send(),                    onCancel: () => setReplying(false),                  })}                />                <div className="mt-2 flex justify-end gap-2">                  <Button size="xs" variant="ghost" onClick={() => setReplying(false)}>                    Cancel                  </Button>                  <Button                    size="xs"                    disabled={pending || reply.trim().length === 0}                    onClick={() => void send()}                  >                    Reply                  </Button>                </div>              </div>            ) : (              <Button                size="xs"                variant="ghost"                className="mt-2 px-1"                onClick={() => setReplying(true)}              >                Reply              </Button>            )          ) : null}        </>      ) : null}    </div>  );}