Vectrum
Sign In
180 lines · 8.1 KB · TypeScript
"use client";

import { useRouter } from "next/navigation";
import { Fragment, useState, useTransition, type ReactNode } from "react";
import { Textarea } from "@unlocalhosted/metalui";
import { lineCommentAction } from "@/app/actions/pulls";
import { FileIcon, PlusIcon } from "@/components/icons";
import { Alert, Box, Button, ButtonLink, Label } from "@/components/ui";
import type { DiffLine, FileDiff } from "@/lib/git";
import { lineKey } from "./line-key";

const MAX_RENDERED_LINES = 3000;

const lineStyles = {
  add: { row: "bg-[var(--diff-add)]", num: "bg-[var(--diff-add-num)]", sign: "+" },
  del: { row: "bg-[var(--diff-del)]", num: "bg-[var(--diff-del-num)]", sign: "-" },
  ctx: { row: "", num: "", sign: " " },
};


function keyOf(file: FileDiff, line: DiffLine) {
  if (line.kind === "del") return { side: "old" as const, path: file.oldPath, line: line.oldNo! };
  return { side: "new" as const, path: file.newPath, line: line.newNo! };
}

function NewComment({ owner, name, number, at, onDone }: { owner: string; name: string; number: number; at: ReturnType<typeof keyOf>; onDone: () => void }) {
  const router = useRouter();
  const [body, setBody] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();
  const submit = () =>
    startTransition(async () => {
      const result = await lineCommentAction(owner, name, number, { ...at, body });
      if (result.error) return setError(result.error);
      onDone();
      router.refresh();
    });
  return (
    <div className="space-y-2 px-3 py-3 font-sans">
      <Textarea value={body} onChange={(e) => setBody(e.target.value)} minRows={3} maxRows={12} aria-label="Comment on this line" placeholder="Comment on this line… Markdown is supported." autoFocus />
      {error && <Alert>{error}</Alert>}
      <div className="flex justify-end gap-2">
        <Button type="button" onClick={onDone}>
          Cancel
        </Button>
        <Button type="button" variant="primary" disabled={pending || !body.trim()} onClick={submit}>
          Comment
        </Button>
      </div>
    </div>
  );
}

/**
 * A pull request's changes, file by file, with the review threads under the lines they're about
 * and, for people who can comment, a + beside every line to start one.
 */
export function PullDiff({
  owner,
  name,
  number,
  files,
  threads,
  canComment,
  viewBase,
}: {
  owner: string;
  name: string;
  number: number;
  files: FileDiff[];
  /** Threads on current lines, by lineKey(). */
  threads: Record<string, ReactNode>;
  canComment: boolean;
  /** Where files are viewed at the head: …/blob/<head>. */
  viewBase: string;
}) {
  const [open, setOpen] = useState<string | null>(null);
  // What each file may render: what is left of the budget after the files above it.
  const budgets = files.map((_, i) => MAX_RENDERED_LINES - files.slice(0, i).reduce((n, f) => n + f.lines.length, 0));
  return (
    <div>
      {files.map((file, index) => {
        const label = file.status === "renamed" ? `${file.oldPath} → ${file.newPath}` : file.newPath;
        const tooBig = file.lines.length > budgets[index];
        return (
          <section key={file.newPath} id={encodeURIComponent(file.newPath)} aria-label={label} className="mb-4 scroll-mt-20">
            <Box className="overflow-clip">
              <div className="rule-b sticky top-14 z-10 flex min-h-11 items-center gap-3 bg-[var(--mu-frost-strong)] py-1.5 pr-2 pl-4 backdrop-blur-md">
                <span className="flex shrink-0 items-center gap-2 type-readout">
                  <span className="text-success-emphasis">+{file.additions}</span>
                  <span className="text-danger">−{file.deletions}</span>
                </span>
                <span className="min-w-0 truncate font-mono text-[11.5px] font-medium text-ink">{label}</span>
                {file.status !== "modified" && (
                  <Label tone={file.status === "added" ? "success" : file.status === "deleted" ? "danger" : "default"}>{file.status}</Label>
                )}
                {file.status !== "deleted" && (
                  <ButtonLink
                    href={`${viewBase}/${file.newPath.split("/").map(encodeURIComponent).join("/")}`}
                    variant="invisible"
                    icon={<FileIcon width={14} height={14} />}
                    className="ml-auto shrink-0"
                  >
                    View File
                  </ButtonLink>
                )}
              </div>
              {file.binary ? (
                <p className="p-6 text-center type-ui text-ink3">Binary file not shown.</p>
              ) : file.lines.length === 0 ? (
                <p className="p-6 text-center type-ui text-ink3">File mode or name changed only.</p>
              ) : tooBig ? (
                <p className="p-6 text-center type-ui text-ink3">Large diffs are not rendered. Read this file’s changes in the commits.</p>
              ) : (
                <div className="overflow-x-auto">
                  <table className="w-full border-collapse font-mono text-[11.5px] leading-[22px] text-ink">
                    <tbody>
                      {file.lines.map((line, i) => {
                        if (line.kind === "hunk") {
                          return (
                            <tr key={i} className="bg-[var(--diff-hunk)] text-ink3">
                              <td colSpan={3} className="px-4 py-1.5">
                                {line.text}
                              </td>
                            </tr>
                          );
                        }
                        const at = keyOf(file, line);
                        const key = lineKey(at.side, at.path, at.line);
                        const style = lineStyles[line.kind];
                        return (
                          <Fragment key={i}>
                            <tr className={`group ${style.row}`}>
                              <td className={`relative w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${style.num}`}>
                                {canComment && (
                                  <button
                                    type="button"
                                    aria-label={`Comment on line ${at.line}`}
                                    onClick={() => setOpen(open === key ? null : key)}
                                    className="absolute top-0.5 left-0.5 flex size-[18px] items-center justify-center rounded-[6px] bg-green-deep text-white opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
                                  >
                                    <PlusIcon width={12} height={12} />
                                  </button>
                                )}
                                {line.oldNo}
                              </td>
                              <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${style.num}`}>{line.newNo}</td>
                              <td className="px-2 whitespace-pre">
                                <span className="select-none text-ink3">{style.sign} </span>
                                {line.text}
                              </td>
                            </tr>
                            {threads[key] && (
                              <tr>
                                <td colSpan={3} className="px-3 py-2">
                                  {threads[key]}
                                </td>
                              </tr>
                            )}
                            {open === key && (
                              <tr>
                                <td colSpan={3}>
                                  <NewComment owner={owner} name={name} number={number} at={at} onDone={() => setOpen(null)} />
                                </td>
                              </tr>
                            )}
                          </Fragment>
                        );
                      })}
                    </tbody>
                  </table>
                </div>
              )}
            </Box>
          </section>
        );
      })}
    </div>
  );
}