"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>
);
}