| ... | ... | @@ -12,12 +12,15 @@ import { |
| 12 | 12 | ChevronDownIcon, |
| 13 | 13 | ChevronRightIcon, |
| 14 | 14 | HammerIcon, |
| 15 | ListFilterIcon, |
| 15 | 16 | TagIcon, |
| 16 | 17 | UsersIcon, |
| 17 | 18 | } from "lucide-react"; |
| 19 | import * as Schema from "effect/Schema"; |
| 18 | 20 | import { useRef, useState, type ReactNode } from "react"; |
| 19 | 21 | |
| 20 | 22 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 23 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 21 | 24 | import { cn } from "~/lib/utils"; |
| 22 | 25 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 23 | 26 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| ... | ... | @@ -25,6 +28,14 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 25 | 28 | import { Button } from "../ui/button"; |
| 26 | 29 | import { PullRequestEditButton } from "./PullRequestEditButton"; |
| 27 | 30 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 31 | import { |
| 32 | Menu, |
| 33 | MenuCheckboxItem, |
| 34 | MenuGroup, |
| 35 | MenuGroupLabel, |
| 36 | MenuPopup, |
| 37 | MenuTrigger, |
| 38 | } from "../ui/menu"; |
| 28 | 39 | import { toastManager } from "../ui/toast"; |
| 29 | 40 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 30 | 41 | import { |
| ... | ... | @@ -349,111 +360,21 @@ function Section({ |
| 349 | 360 | ); |
| 350 | 361 | } |
| 351 | 362 | |
| 352 | | function CommentGroup({ |
| 353 | | label, |
| 354 | | comments, |
| 355 | | detail, |
| 356 | | children, |
| 357 | | onOpenChange, |
| 358 | | }: { |
| 359 | | label: string; |
| 360 | | comments: readonly PullRequestComment[]; |
| 361 | | detail: PullRequestDetailView; |
| 362 | | children: ReactNode; |
| 363 | | onOpenChange?: (open: boolean) => void; |
| 364 | | }) { |
| 365 | | const authors = [ |
| 366 | | ...new Map( |
| 367 | | comments.map((comment) => [reviewerKey(comment.author?.login ?? "ghost"), comment.author]), |
| 368 | | ).values(), |
| 369 | | ]; |
| 370 | | const fileCount = new Set(comments.flatMap((comment) => (comment.path ? [comment.path] : []))) |
| 371 | | .size; |
| 372 | | const latest = comments.reduce<string | null>( |
| 373 | | (date, comment) => (date === null || comment.createdAt > date ? comment.createdAt : date), |
| 374 | | null, |
| 375 | | ); |
| 376 | | return ( |
| 377 | | <div className="overflow-hidden rounded-lg border border-border bg-background"> |
| 378 | | <Collapsible onOpenChange={onOpenChange}> |
| 379 | | <div className="flex items-center gap-3 pl-3"> |
| 380 | | <div className="flex shrink-0 -space-x-1.5"> |
| 381 | | {authors.slice(0, 3).map((actor) => ( |
| 382 | | // The ring separates the overlapping faces; it belongs to the stack, not the actor. |
| 383 | | <span |
| 384 | | key={actor?.login ?? "ghost"} |
| 385 | | className="relative flex rounded-full ring-2 ring-background hover:z-10 focus-within:z-10" |
| 386 | | > |
| 387 | | <PullRequestActorLabel |
| 388 | | actor={actor} |
| 389 | | profileUrl={ |
| 390 | | detail.provider === "github" && actor |
| 391 | | ? new URL( |
| 392 | | actor.isBot || actor.login.endsWith("[bot]") |
| 393 | | ? `/apps/${encodeURIComponent(actor.login.replace(/\[bot\]$/, ""))}` |
| 394 | | : `/${encodeURIComponent(actor.login)}`, |
| 395 | | detail.url, |
| 396 | | ).toString() |
| 397 | | : null |
| 398 | | } |
| 399 | | variant="avatar" |
| 400 | | /> |
| 401 | | </span> |
| 402 | | ))} |
| 403 | | {authors.length > 3 ? ( |
| 404 | | <span className="relative flex size-6 items-center justify-center rounded-full bg-muted text-3xs text-muted-foreground ring-2 ring-background"> |
| 405 | | +{authors.length - 3} |
| 406 | | </span> |
| 407 | | ) : null} |
| 408 | | </div> |
| 409 | | <CollapsibleTrigger |
| 410 | | aria-label={label} |
| 411 | | className="group flex min-w-0 flex-1 items-center gap-3 rounded-md py-3 pr-3 text-left hover:bg-muted/30" |
| 412 | | > |
| 413 | | <span className="min-w-0 flex-1 space-y-1"> |
| 414 | | <span className="block text-xs font-medium text-foreground/90">{label}</span> |
| 415 | | <span className="flex flex-wrap gap-x-1.5 text-2xs text-muted-foreground"> |
| 416 | | <span> |
| 417 | | {authors.length} {authors.length === 1 ? "author" : "authors"} |
| 418 | | </span> |
| 419 | | {fileCount > 0 ? ( |
| 420 | | <span> |
| 421 | | · {fileCount} {fileCount === 1 ? "file" : "files"} |
| 422 | | </span> |
| 423 | | ) : null} |
| 424 | | {latest ? ( |
| 425 | | <span> |
| 426 | | · Latest{" "} |
| 427 | | <Tooltip> |
| 428 | | <TooltipTrigger render={<time dateTime={latest} />}> |
| 429 | | {formatRelativeTimeLabel(latest)} |
| 430 | | </TooltipTrigger> |
| 431 | | <TooltipPopup>{new Date(latest).toLocaleString()}</TooltipPopup> |
| 432 | | </Tooltip> |
| 433 | | </span> |
| 434 | | ) : null} |
| 435 | | </span> |
| 436 | | </span> |
| 437 | | <ChevronRightIcon |
| 438 | | aria-hidden |
| 439 | | className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" |
| 440 | | /> |
| 441 | | </CollapsibleTrigger> |
| 442 | | </div> |
| 443 | | <CollapsiblePanel keepMounted> |
| 444 | | <div className="border-t border-border/60 px-3 pb-3">{children}</div> |
| 445 | | </CollapsiblePanel> |
| 446 | | </Collapsible> |
| 447 | | </div> |
| 448 | | ); |
| 449 | | } |
| 450 | | |
| 451 | 363 | /** |
| 452 | 364 | * What a first render of the conversation carries. A pull request with two hundred comments is |
| 453 | 365 | * two hundred markdown documents, and the ones worth arriving for are the recent ones. |
| 454 | 366 | */ |
| 455 | 367 | const COMMENT_PAGE = 10; |
| 456 | 368 | |
| 369 | /** |
| 370 | * Which kinds of comment the conversation shows. Kept per browser rather than per pull request: |
| 371 | * whether a reader wants bot reports and finished threads in front of them is a habit, not a |
| 372 | * property of one change. |
| 373 | */ |
| 374 | const COMMENT_FILTER_STORAGE_KEY = "t3code:pull-request-comment-filter:v1"; |
| 375 | const CommentFilterSchema = Schema.Struct({ bots: Schema.Boolean, resolved: Schema.Boolean }); |
| 376 | const DEFAULT_COMMENT_FILTER: typeof CommentFilterSchema.Type = { bots: false, resolved: false }; |
| 377 | |
| 457 | 378 | export function PullRequestSummaryTab({ |
| 458 | 379 | environmentId, |
| 459 | 380 | threadRef, |
| ... | ... | @@ -487,10 +408,12 @@ export function PullRequestSummaryTab({ |
| 487 | 408 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| 488 | 409 | // rather than wherever the last one had been read back to. |
| 489 | 410 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| 490 | | const [openedBotGroup, setOpenedBotGroup] = useState<string | null>(null); |
| 491 | | const [shownBots, setShownBots] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| 492 | | const shownBotComments = shownBots.url === detail.url ? shownBots.count : COMMENT_PAGE; |
| 493 | 411 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; |
| 412 | const [commentFilter, setCommentFilter] = useLocalStorage( |
| 413 | COMMENT_FILTER_STORAGE_KEY, |
| 414 | DEFAULT_COMMENT_FILTER, |
| 415 | CommentFilterSchema, |
| 416 | ); |
| 494 | 417 | // A comment that already lives on a review thread is that thread: the thread carries the line |
| 495 | 418 | // and side the bare comment has lost, and a resolved one is finished work nobody should be |
| 496 | 419 | // invited to fix again — the same call the whole-review hand-off makes. |
| ... | ... | @@ -500,22 +423,24 @@ export function PullRequestSummaryTab({ |
| 500 | 423 | ), |
| 501 | 424 | ); |
| 502 | 425 | |
| 503 | | const activeComments: PullRequestComment[] = []; |
| 504 | | const finishedComments: PullRequestComment[] = []; |
| 505 | | const botComments: PullRequestComment[] = []; |
| 506 | | for (const comment of detail.comments) { |
| 507 | | const finished = |
| 508 | | threadByCommentId.get(comment.id)?.isResolved || |
| 509 | | pullRequestReviewOutcome(comment.reviewState) === "dismissed"; |
| 510 | | const bot = comment.author?.isBot === true || comment.author?.login.endsWith("[bot]"); |
| 511 | | (finished ? finishedComments : bot ? botComments : activeComments).push(comment); |
| 512 | | } |
| 426 | const isFinished = (comment: PullRequestComment) => |
| 427 | threadByCommentId.get(comment.id)?.isResolved === true || |
| 428 | pullRequestReviewOutcome(comment.reviewState) === "dismissed"; |
| 429 | const isBot = (comment: PullRequestComment) => |
| 430 | comment.author?.isBot === true || comment.author?.login.endsWith("[bot]") === true; |
| 431 | const filteredComments = detail.comments.filter( |
| 432 | (comment) => |
| 433 | (commentFilter.bots || !isBot(comment)) && (commentFilter.resolved || !isFinished(comment)), |
| 434 | ); |
| 435 | // The one count the filter shows. Per-kind counts would overlap, since a resolved bot comment |
| 436 | // is both kinds and needs both shown. |
| 437 | const filteredOutCount = detail.comments.length - filteredComments.length; |
| 513 | 438 | // Windowed by recency regardless of display order: expanding always reaches further back in |
| 514 | 439 | // time, whether the newest comment currently reads first or last. |
| 515 | | const recentComments = activeComments.slice(Math.max(0, activeComments.length - shownComments)); |
| 516 | | const hiddenCommentCount = activeComments.length - recentComments.length; |
| 517 | | const recentBotComments = botComments.slice(Math.max(0, botComments.length - shownBotComments)); |
| 518 | | const hiddenBotCommentCount = botComments.length - recentBotComments.length; |
| 440 | const recentComments = filteredComments.slice( |
| 441 | Math.max(0, filteredComments.length - shownComments), |
| 442 | ); |
| 443 | const hiddenCommentCount = filteredComments.length - recentComments.length; |
| 519 | 444 | const [commentOrder, setCommentOrder] = useState<"newest" | "oldest">("newest"); |
| 520 | 445 | const visibleComments = orderPullRequestComments(recentComments, commentOrder); |
| 521 | 446 | const showOldestCommentsButton = |
| ... | ... | @@ -638,6 +563,30 @@ export function PullRequestSummaryTab({ |
| 638 | 563 | const renderComment = (comment: PullRequestComment) => { |
| 639 | 564 | const thread = threadByCommentId.get(comment.id); |
| 640 | 565 | const body = visibleBody(comment.body); |
| 566 | if (isFinished(comment)) { |
| 567 | return ( |
| 568 | <CollapsedComment |
| 569 | key={`${detail.url}:${comment.id}`} |
| 570 | comment={comment} |
| 571 | editing={commentEditing} |
| 572 | detail={detail} |
| 573 | thread={thread} |
| 574 | label={thread?.isResolved ? "Resolved" : "Review dismissed"} |
| 575 | body={body} |
| 576 | reactionBar={ |
| 577 | <PullRequestReactionBar |
| 578 | className="ml-auto justify-end" |
| 579 | reactions={comment.reactions ?? []} |
| 580 | canReact={detail.capabilities.reactions === true} |
| 581 | subjectId={comment.id} |
| 582 | environmentId={environmentId} |
| 583 | reference={reference} |
| 584 | onRefresh={onRefresh} |
| 585 | /> |
| 586 | } |
| 587 | /> |
| 588 | ); |
| 589 | } |
| 641 | 590 | const outcome = pullRequestReviewOutcome(comment.reviewState); |
| 642 | 591 | // An approval is a verdict, not a finding: there is nothing in it to fix. |
| 643 | 592 | const finding: PullRequestFinding | null = |
| ... | ... | @@ -920,20 +869,51 @@ export function PullRequestSummaryTab({ |
| 920 | 869 | <Section |
| 921 | 870 | title={`Comments (${detail.commentCount})`} |
| 922 | 871 | actions={ |
| 923 | | <Button |
| 924 | | size="xs" |
| 925 | | variant="ghost-muted" |
| 926 | | className="shrink-0" |
| 927 | | aria-label={ |
| 928 | | commentOrder === "newest" |
| 929 | | ? "Show oldest comments first" |
| 930 | | : "Show newest comments first" |
| 931 | | } |
| 932 | | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} |
| 933 | | > |
| 934 | | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 935 | | {commentOrder === "newest" ? "Newest first" : "Oldest first"} |
| 936 | | </Button> |
| 872 | <div className="flex shrink-0 items-center gap-1"> |
| 873 | <Menu> |
| 874 | <MenuTrigger |
| 875 | render={<Button size="xs" variant="ghost-muted" />} |
| 876 | aria-label="Filter comments" |
| 877 | > |
| 878 | <ListFilterIcon aria-hidden className="size-3" /> |
| 879 | {filteredOutCount > 0 ? `${filteredOutCount} hidden` : "Filter"} |
| 880 | </MenuTrigger> |
| 881 | <MenuPopup align="end"> |
| 882 | <MenuGroup> |
| 883 | <MenuGroupLabel>Show</MenuGroupLabel> |
| 884 | <MenuCheckboxItem |
| 885 | checked={commentFilter.bots} |
| 886 | closeOnClick={false} |
| 887 | onCheckedChange={(bots) => setCommentFilter((value) => ({ ...value, bots }))} |
| 888 | > |
| 889 | Bot comments |
| 890 | </MenuCheckboxItem> |
| 891 | <MenuCheckboxItem |
| 892 | checked={commentFilter.resolved} |
| 893 | closeOnClick={false} |
| 894 | onCheckedChange={(resolved) => |
| 895 | setCommentFilter((value) => ({ ...value, resolved })) |
| 896 | } |
| 897 | > |
| 898 | Resolved or dismissed |
| 899 | </MenuCheckboxItem> |
| 900 | </MenuGroup> |
| 901 | </MenuPopup> |
| 902 | </Menu> |
| 903 | <Button |
| 904 | size="xs" |
| 905 | variant="ghost-muted" |
| 906 | aria-label={ |
| 907 | commentOrder === "newest" |
| 908 | ? "Show oldest comments first" |
| 909 | : "Show newest comments first" |
| 910 | } |
| 911 | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} |
| 912 | > |
| 913 | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 914 | {commentOrder === "newest" ? "Newest first" : "Oldest first"} |
| 915 | </Button> |
| 916 | </div> |
| 937 | 917 | } |
| 938 | 918 | > |
| 939 | 919 | {activityPending ? ( |
| ... | ... | @@ -950,6 +930,12 @@ export function PullRequestSummaryTab({ |
| 950 | 930 | ) : null} |
| 951 | 931 | {detail.comments.length === 0 ? ( |
| 952 | 932 | <p className="py-2 text-xs text-muted-foreground">No comments yet.</p> |
| 933 | ) : filteredComments.length === 0 ? ( |
| 934 | <p className="py-2 text-xs text-muted-foreground"> |
| 935 | {filteredOutCount === 1 |
| 936 | ? "The only comment is hidden by the filter." |
| 937 | : `All ${filteredOutCount} comments are hidden by the filter.`} |
| 938 | </p> |
| 953 | 939 | ) : ( |
| 954 | 940 | <div className="space-y-3"> |
| 955 | 941 | {commentOrder === "oldest" ? showOldestCommentsButton : null} |
| ... | ... | @@ -965,87 +951,6 @@ export function PullRequestSummaryTab({ |
| 965 | 951 | Show only {COMMENT_PAGE} recent comments |
| 966 | 952 | </Button> |
| 967 | 953 | ) : null} |
| 968 | | {botComments.length > 0 ? ( |
| 969 | | <CommentGroup |
| 970 | | key={`bots:${detail.url}`} |
| 971 | | label={`${botComments.length} bot comment${botComments.length === 1 ? "" : "s"}`} |
| 972 | | comments={botComments} |
| 973 | | detail={detail} |
| 974 | | onOpenChange={(open) => { |
| 975 | | if (open) setOpenedBotGroup(detail.url); |
| 976 | | }} |
| 977 | | > |
| 978 | | <div className="space-y-3 pt-2"> |
| 979 | | {openedBotGroup === detail.url |
| 980 | | ? orderPullRequestComments(recentBotComments, commentOrder).map( |
| 981 | | renderComment, |
| 982 | | ) |
| 983 | | : null} |
| 984 | | {hiddenBotCommentCount > 0 ? ( |
| 985 | | <Button |
| 986 | | size="sm" |
| 987 | | variant="outline" |
| 988 | | className="w-full" |
| 989 | | onClick={() => |
| 990 | | setShownBots({ |
| 991 | | url: detail.url, |
| 992 | | count: shownBotComments + COMMENT_PAGE, |
| 993 | | }) |
| 994 | | } |
| 995 | | > |
| 996 | | Show {Math.min(hiddenBotCommentCount, COMMENT_PAGE)} older bot comment |
| 997 | | {hiddenBotCommentCount === 1 ? "" : "s"} ({hiddenBotCommentCount} hidden) |
| 998 | | </Button> |
| 999 | | ) : null} |
| 1000 | | {shownBotComments > COMMENT_PAGE ? ( |
| 1001 | | <Button |
| 1002 | | size="sm" |
| 1003 | | variant="ghost" |
| 1004 | | className="w-full" |
| 1005 | | onClick={() => setShownBots({ url: detail.url, count: COMMENT_PAGE })} |
| 1006 | | > |
| 1007 | | Show only {COMMENT_PAGE} recent bot comments |
| 1008 | | </Button> |
| 1009 | | ) : null} |
| 1010 | | </div> |
| 1011 | | </CommentGroup> |
| 1012 | | ) : null} |
| 1013 | | {finishedComments.length > 0 ? ( |
| 1014 | | <CommentGroup |
| 1015 | | key={detail.url} |
| 1016 | | label={`${finishedComments.length} resolved or dismissed comment${finishedComments.length === 1 ? "" : "s"}`} |
| 1017 | | comments={finishedComments} |
| 1018 | | detail={detail} |
| 1019 | | > |
| 1020 | | <div className="space-y-2 pt-2"> |
| 1021 | | {orderPullRequestComments(finishedComments, commentOrder).map((comment) => { |
| 1022 | | const thread = threadByCommentId.get(comment.id); |
| 1023 | | return ( |
| 1024 | | <CollapsedComment |
| 1025 | | key={comment.id} |
| 1026 | | comment={comment} |
| 1027 | | editing={commentEditing} |
| 1028 | | detail={detail} |
| 1029 | | thread={thread} |
| 1030 | | label={thread?.isResolved ? "Resolved" : "Review dismissed"} |
| 1031 | | body={visibleBody(comment.body)} |
| 1032 | | reactionBar={ |
| 1033 | | <PullRequestReactionBar |
| 1034 | | className="ml-auto justify-end" |
| 1035 | | reactions={comment.reactions ?? []} |
| 1036 | | canReact={detail.capabilities.reactions === true} |
| 1037 | | subjectId={comment.id} |
| 1038 | | environmentId={environmentId} |
| 1039 | | reference={reference} |
| 1040 | | onRefresh={onRefresh} |
| 1041 | | /> |
| 1042 | | } |
| 1043 | | /> |
| 1044 | | ); |
| 1045 | | })} |
| 1046 | | </div> |
| 1047 | | </CommentGroup> |
| 1048 | | ) : null} |
| 1049 | 954 | </div> |
| 1050 | 955 | )} |
| 1051 | 956 | </> |