);
}
function Section({
title,
count,
defaultOpen = true,
actions,
children,
}: {
title: string;
count?: number;
defaultOpen?: boolean;
/** Controls riding on the heading row itself. A sibling of the trigger, not a child of it —
a button cannot hold a button — and only while open, since they act on what is shown. */
actions?: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
const headingRef = useRef(null);
const setOpenWithScrollAnchor = (nextOpen: boolean) => {
if (!nextOpen) {
const heading = headingRef.current;
const section = heading?.closest("[data-pull-request-summary-section]");
const scroller = heading?.closest("[data-pull-request-summary-scroll]");
if (heading && section && scroller) {
const target = sectionCollapseAnchorScrollTop({
scrollTop: scroller.scrollTop,
viewportTop: scroller.getBoundingClientRect().top,
sectionTop: section.getBoundingClientRect().top,
headingTop: heading.getBoundingClientRect().top,
});
// Synchronous with the press: React commits the collapsed height before the browser
// paints, so the reader sees the heading they pressed stay put rather than a jump first.
if (target !== null) scroller.scrollTop = target;
}
}
setOpen(nextOpen);
};
return (
{/* The heading rides the top of the scroll box the way a diff's file header does, so a
section can be collapsed from wherever its body has been read to rather than only from
where it started. Opaque, because the rows it covers scroll beneath it. */}
{/* Title first, chevron riding to its right, count last: the row reads as a heading
with an affordance rather than a tree node. */}
{title}
{count === undefined ? null : (
{count}
)}
{open ? actions : null}
);
}
/**
* What a first render of the conversation carries. A pull request with two hundred comments is
* two hundred markdown documents, and the ones worth arriving for are the recent ones.
*/
const COMMENT_PAGE = 30;
export function PullRequestSummaryTab({
environmentId,
reference,
detail,
activityPending,
activityError,
pendingFinding,
fixFindingLabel = "Fix in a thread",
fixCheckLabel = "Fix",
onFixFinding,
onRefresh,
}: {
environmentId: EnvironmentId;
reference: PullRequestRef;
detail: PullRequestDetailView;
activityPending: boolean;
activityError: string | null;
/** The hand-off currently preparing, if any, so only the finding it belongs to says so. */
pendingFinding?: string | null;
fixFindingLabel?: string;
fixCheckLabel?: string;
onFixFinding?: (finding: PullRequestFinding) => void;
onRefresh: () => void;
}) {
// Keyed by the pull request, so opening another one starts at the end of its conversation
// rather than wherever the last one had been read back to.
const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE });
const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE;
// Windowed by recency regardless of display order: expanding always reaches further back in
// time, whether the newest comment currently reads first or last.
const recentComments = detail.comments.slice(Math.max(0, detail.comments.length - shownComments));
const hiddenCommentCount = detail.comments.length - recentComments.length;
const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest");
const visibleComments = orderPullRequestComments(recentComments, commentOrder);
// A comment that already lives on a review thread is that thread: the thread carries the line
// and side the bare comment has lost, and a resolved one is finished work nobody should be
// invited to fix again — the same call the whole-review hand-off makes.
const threadByCommentId = new Map(
detail.reviewThreads.flatMap((thread) =>
thread.comments.map((comment) => [comment.id, thread] as const),
),
);
const openCheck = (url: string) => {
void readLocalApi()?.shell.openExternal(url);
};
const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false });
const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, {
reportFailure: false,
});
// Keyed by the pull request, like the comment window above it, so an editor left open never
// reappears over the next pull request's description.
const [bodyScope, setBodyScope] = useState(null);
const [bodySaving, setBodySaving] = useState(false);
// The remark being rewritten, named with the pull request it belongs to: a comment id is the
// host's own, and two hosts — or two pull requests on Azure DevOps, which numbers a remark
// inside its thread — hand out the same one. Without the pull request beside it, opening a
// different one would leave its like-numbered remark sitting open in an editor.
const [commentScope, setCommentScope] = useState<{
readonly pullRequest: string;
readonly commentId: string;
} | null>(null);
const [commentSaving, setCommentSaving] = useState(false);
const editingCommentId = commentScope?.pullRequest === detail.url ? commentScope.commentId : null;
const saveBody = async (body: string) => {
if (bodySaving) return;
setBodySaving(true);
const result = await update({ environmentId, input: { ...reference, body } });
setBodySaving(false);
if (result._tag === "Failure") {
toastManager.add({ type: "error", title: "Could not save the description" });
return;
}
setBodyScope(null);
onRefresh();
};
const commentEditing: CommentEditing = {
cwd: detail.workspaceRoot,
canEdit: (comment) => canEditPullRequestComment(detail, comment),
editingId: editingCommentId,
saving: commentSaving,
onEdit: (comment) =>
setCommentScope(comment === null ? null : { pullRequest: detail.url, commentId: comment.id }),
onSave: async (comment, body) => {
// A review's own summary is not a kind any host rewrites, which is why no pencil is ever
// offered on one; the check is here because the comment's own type still allows it.
if (commentSaving || comment.kind === "review") return;
setCommentSaving(true);
const result = await updateComment({
environmentId,
input: { ...reference, commentId: comment.id, kind: comment.kind, body },
});
setCommentSaving(false);
if (result._tag === "Failure") {
toastManager.add({ type: "error", title: "Could not save the comment" });
return;
}
setCommentScope(null);
onRefresh();
},
};
return (
} label="Reviewers">
{detail.reviewers.length === 0 ? (
None
) : (
{detail.reviewers.map((actor) => (
}
>
{actor.name && actor.name !== actor.login
? `${actor.name} (@${actor.login})`
: actor.login}
))}
)}
{/* Shown wherever the host can take a review request at all, and disabled with the
reason where this account may not make one: a control that vanishes teaches
nobody why, and "you need write access" is the answer to the question a reader
actually has. Azure DevOps is the exception — it takes a reviewer but will not
say who could be one, so there is nothing to open. */}
{detail.capabilities.reviewers.request &&
detail.capabilities.reviewers.listCandidates ? (
) : null}
{detail.labels.length > 0 ? (
} label="Labels">
{detail.labels.map((label) => {
const dot = labelDotColor(label.color);
return (
{label.name}
);
})}
) : null}
} label="Comments">
{activityPending
? "Loading conversation…"
: activityError
? "Conversation unavailable"
: detail.commentCount === 1
? "1 comment"
: `${detail.commentCount} comments`}
{/* Only where there is something to fix. A passing check has no failure to
reproduce, and the button would be an invitation to waste a thread. */}
{onFixFinding && failing ? (
) : null}
This conversation is longer than this page reads in one go. The most recent{" "}
{detail.comments.length} are here; open it on the host to read the rest.
) : null}
{detail.comments.length === 0 ? (
No comments yet.
) : (
{hiddenCommentCount > 0 ? (
// Hundreds of comments are hundreds of markdown renders, and the ones worth
// opening a pull request for are the recent ones. The rest are one press away and
// stay rendered once asked for.
) : null}
{visibleComments.map((comment) => {
const thread = threadByCommentId.get(comment.id);
const reviewState = comment.reviewState?.toLowerCase();
if (thread?.isResolved || reviewState === "dismissed") {
return (
}
/>
);
}
// An approval is a verdict, not a finding: there is nothing in it to fix.
const finding: PullRequestFinding | null =
(comment.kind !== "review" && comment.kind !== "review-comment") ||
reviewState === "approved"
? null
: thread === undefined
? { kind: "comment", comment }
: { kind: "thread", thread };
return (
{formatRelativeTimeLabel(comment.createdAt)}
{comment.reviewState ? (
{reviewStateLabel(comment.reviewState)}
) : null}
{/* Review remarks only. A plain conversation comment is talk, not a finding,
and offering to fix one would promise more than it says. */}
{onFixFinding && finding ? (
) : null}
{comment.path ? (
{comment.path}
) : null}
);
})}
)}
>
)}
{/* Posting is a core capability and remains usable even if the activity read failed. */}
{detail.capabilities.comment && detail.viewerPermissions.comment ? (
) : null}