| ... | ... | @@ -12,14 +12,13 @@ import { |
| 12 | 12 | ChevronRightIcon, |
| 13 | 13 | GitPullRequestClosedIcon, |
| 14 | 14 | HammerIcon, |
| 15 | | MessageSquareIcon, |
| 16 | 15 | PencilIcon, |
| 17 | 16 | RotateCcwIcon, |
| 18 | 17 | SendIcon, |
| 19 | 18 | TagIcon, |
| 20 | 19 | UsersIcon, |
| 21 | 20 | } from "lucide-react"; |
| 22 | | import { useRef, useState, type ReactNode } from "react"; |
| 21 | import { useId, useRef, useState, type ReactNode } from "react"; |
| 23 | 22 | |
| 24 | 23 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | 24 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| ... | ... | @@ -187,7 +186,7 @@ function CollapsedComment({ |
| 187 | 186 | <ChevronDownIcon |
| 188 | 187 | aria-hidden |
| 189 | 188 | className={cn( |
| 190 | | "size-3.5 shrink-0 text-muted-foreground transition-transform", |
| 189 | "size-3.5 shrink-0 text-muted-foreground/60 transition-transform", |
| 191 | 190 | open && "rotate-180", |
| 192 | 191 | )} |
| 193 | 192 | /> |
| ... | ... | @@ -229,8 +228,8 @@ function MetaRow({ |
| 229 | 228 | children: ReactNode; |
| 230 | 229 | }) { |
| 231 | 230 | return ( |
| 232 | | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs"> |
| 233 | | <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground"> |
| 231 | <div className="grid min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs"> |
| 232 | <span className="flex items-center gap-1.5 text-muted-foreground"> |
| 234 | 233 | {icon} |
| 235 | 234 | {label} |
| 236 | 235 | </span> |
| ... | ... | @@ -241,16 +240,13 @@ function MetaRow({ |
| 241 | 240 | |
| 242 | 241 | function Section({ |
| 243 | 242 | title, |
| 244 | | count, |
| 245 | 243 | defaultOpen = true, |
| 246 | 244 | actions, |
| 247 | 245 | children, |
| 248 | 246 | }: { |
| 249 | 247 | title: string; |
| 250 | | count?: number; |
| 251 | 248 | defaultOpen?: boolean; |
| 252 | | /** Controls riding on the heading row itself. A sibling of the trigger, not a child of it — |
| 253 | | a button cannot hold a button — and only while open, since they act on what is shown. */ |
| 249 | /** Heading controls stay separate from the collapse trigger so they remain independently usable. */ |
| 254 | 250 | actions?: ReactNode; |
| 255 | 251 | children: ReactNode; |
| 256 | 252 | }) { |
| ... | ... | @@ -286,24 +282,19 @@ function Section({ |
| 286 | 282 | where it started. Opaque, because the rows it covers scroll beneath it. */} |
| 287 | 283 | <div |
| 288 | 284 | ref={headingRef} |
| 289 | | className="sticky top-0 z-10 flex w-full items-center border-t border-border/60 bg-background pr-4" |
| 285 | className="sticky top-0 z-10 flex w-full items-center bg-background pr-4" |
| 290 | 286 | > |
| 291 | | {/* Title first, chevron riding to its right, count last: the row reads as a heading |
| 292 | | with an affordance rather than a tree node. */} |
| 293 | | <CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-sm font-medium"> |
| 287 | <CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-xs font-medium text-muted-foreground hover:text-foreground"> |
| 294 | 288 | <span>{title}</span> |
| 295 | 289 | <ChevronRightIcon |
| 296 | 290 | aria-hidden |
| 297 | 291 | className={cn( |
| 298 | | "size-3.5 text-muted-foreground transition-transform", |
| 292 | "size-3.5 text-muted-foreground/60 transition-transform", |
| 299 | 293 | open && "rotate-90", |
| 300 | 294 | )} |
| 301 | 295 | /> |
| 302 | | {count === undefined ? null : ( |
| 303 | | <span className="text-xs tabular-nums text-muted-foreground">{count}</span> |
| 304 | | )} |
| 305 | 296 | </CollapsibleTrigger> |
| 306 | | {open ? actions : null} |
| 297 | {actions} |
| 307 | 298 | </div> |
| 308 | 299 | <CollapsiblePanel> |
| 309 | 300 | <div className="px-4 pb-4">{children}</div> |
| ... | ... | @@ -461,6 +452,9 @@ export function PullRequestSummaryTab({ |
| 461 | 452 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| 462 | 453 | // rather than wherever the last one had been read back to. |
| 463 | 454 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| 455 | const checksId = useId(); |
| 456 | const [expandedChecksUrl, setExpandedChecksUrl] = useState<string | null>(null); |
| 457 | const showChecks = expandedChecksUrl === detail.url; |
| 464 | 458 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; |
| 465 | 459 | // Windowed by recency regardless of display order: expanding always reaches further back in |
| 466 | 460 | // time, whether the newest comment currently reads first or last. |
| ... | ... | @@ -596,8 +590,8 @@ export function PullRequestSummaryTab({ |
| 596 | 590 | |
| 597 | 591 | return ( |
| 598 | 592 | <div className="h-full overflow-y-auto" data-pull-request-summary-scroll> |
| 599 | | <section className="px-4 py-3"> |
| 600 | | <div> |
| 593 | <section className="px-4 py-2.5"> |
| 594 | <div className="space-y-2"> |
| 601 | 595 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> |
| 602 | 596 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 603 | 597 | {reviewerEntries.length === 0 ? ( |
| ... | ... | @@ -700,7 +694,7 @@ export function PullRequestSummaryTab({ |
| 700 | 694 | return ( |
| 701 | 695 | <span |
| 702 | 696 | key={label.name} |
| 703 | | className="inline-flex max-w-48 items-center gap-1.5 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs" |
| 697 | className="inline-flex max-w-48 items-center gap-1.5 rounded-full bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs" |
| 704 | 698 | > |
| 705 | 699 | <span |
| 706 | 700 | aria-hidden |
| ... | ... | @@ -722,19 +716,10 @@ export function PullRequestSummaryTab({ |
| 722 | 716 | </span> |
| 723 | 717 | </MetaRow> |
| 724 | 718 | ) : null} |
| 725 | | <MetaRow icon={<MessageSquareIcon className="size-3.5" />} label="Comments"> |
| 726 | | {activityPending |
| 727 | | ? "Loading conversation…" |
| 728 | | : activityError |
| 729 | | ? "Conversation unavailable" |
| 730 | | : detail.commentCount === 1 |
| 731 | | ? "1 comment" |
| 732 | | : `${detail.commentCount} comments`} |
| 733 | | </MetaRow> |
| 734 | 719 | </div> |
| 735 | 720 | </section> |
| 736 | 721 | |
| 737 | | <Section title="Description"> |
| 722 | <section aria-label="Description" className="px-4 pt-2 pb-4"> |
| 738 | 723 | <div className="group"> |
| 739 | 724 | {bodyScope === detail.url ? ( |
| 740 | 725 | <PullRequestMarkdownEditor |
| ... | ... | @@ -781,81 +766,96 @@ export function PullRequestSummaryTab({ |
| 781 | 766 | onRefresh={onRefresh} |
| 782 | 767 | /> |
| 783 | 768 | </div> |
| 784 | | </Section> |
| 769 | </section> |
| 785 | 770 | |
| 786 | | <Section title="Checks" count={detail.checks.length}> |
| 771 | <section aria-label="Checks" className="px-4 py-3"> |
| 787 | 772 | {detail.checks.length === 0 ? ( |
| 788 | 773 | <p className="text-xs text-muted-foreground">No checks reported.</p> |
| 789 | 774 | ) : ( |
| 790 | | <div className="space-y-0.5"> |
| 791 | | {detail.checks.map((check, index) => { |
| 792 | | const finding = { kind: "check", check } as const; |
| 793 | | const failing = check.status === "failure" || check.status === "cancelled"; |
| 794 | | return ( |
| 795 | | <div |
| 796 | | // Position too: the host decides how many runs share a name, and a repeated |
| 797 | | // key would be a rendering fault on top of whatever the list already says. |
| 798 | | key={`${index}:${check.name}:${check.url ?? ""}`} |
| 799 | | className="group flex items-center gap-1 rounded-md pr-1 hover:bg-accent/60" |
| 800 | | > |
| 801 | | <button |
| 802 | | type="button" |
| 803 | | disabled={!check.url} |
| 804 | | onClick={() => check.url && openCheck(check.url)} |
| 805 | | className={cn( |
| 806 | | "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs", |
| 807 | | check.url ? undefined : "cursor-default", |
| 808 | | )} |
| 775 | <div> |
| 776 | <div className="flex items-center gap-1 text-xs"> |
| 777 | <span className="font-medium text-muted-foreground">Checks</span> |
| 778 | <Button |
| 779 | size="icon-xs" |
| 780 | variant="ghost-muted" |
| 781 | aria-label={showChecks ? "Hide checks" : "Show checks"} |
| 782 | aria-expanded={showChecks} |
| 783 | aria-controls={checksId} |
| 784 | onClick={() => setExpandedChecksUrl(showChecks ? null : detail.url)} |
| 785 | > |
| 786 | <ChevronRightIcon |
| 787 | aria-hidden |
| 788 | className={cn("size-3.5 text-muted-foreground/60", showChecks && "rotate-90")} |
| 789 | /> |
| 790 | </Button> |
| 791 | </div> |
| 792 | <div id={checksId} className={showChecks ? "mt-2" : "hidden"}> |
| 793 | {(showChecks ? detail.checks : []).map((check, index) => { |
| 794 | const finding = { kind: "check", check } as const; |
| 795 | const failing = check.status === "failure" || check.status === "cancelled"; |
| 796 | return ( |
| 797 | <div |
| 798 | // Position too: the host decides how many runs share a name, and a repeated |
| 799 | // key would be a rendering fault on top of whatever the list already says. |
| 800 | key={`${index}:${check.name}:${check.url ?? ""}`} |
| 801 | className="group flex items-center gap-2 rounded-md pr-1 hover:bg-accent/60" |
| 809 | 802 | > |
| 810 | | <PullRequestCheckStatusIcon status={check.status} /> |
| 811 | | <span className="min-w-0 flex-1 truncate">{check.name}</span> |
| 812 | | <span className="shrink-0 text-muted-foreground"> |
| 813 | | {pullRequestCheckStatusLabel(check)} |
| 814 | | </span> |
| 815 | | </button> |
| 816 | | {/* Only where there is something to fix. A passing check has no failure to |
| 817 | | reproduce, and the button would be an invitation to waste a thread. */} |
| 818 | | {onFixFinding && failing ? ( |
| 819 | | <Button |
| 820 | | size="xs" |
| 821 | | variant="ghost" |
| 822 | | className="shrink-0" |
| 823 | | disabled={pendingFinding !== null && pendingFinding !== undefined} |
| 824 | | onClick={() => onFixFinding(finding)} |
| 803 | <button |
| 804 | type="button" |
| 805 | disabled={!check.url} |
| 806 | onClick={() => check.url && openCheck(check.url)} |
| 807 | className={cn( |
| 808 | "flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-2 text-left text-xs leading-5 [&>svg]:mt-0.5", |
| 809 | check.url ? undefined : "cursor-default", |
| 810 | )} |
| 825 | 811 | > |
| 826 | | <HammerIcon className="size-3" /> |
| 827 | | {pendingFinding === pullRequestFindingKey(finding) |
| 828 | | ? "Preparing..." |
| 829 | | : fixCheckLabel} |
| 830 | | </Button> |
| 831 | | ) : null} |
| 832 | | </div> |
| 833 | | ); |
| 834 | | })} |
| 812 | <PullRequestCheckStatusIcon status={check.status} /> |
| 813 | <span className="min-w-0 flex-1 wrap-anywhere">{check.name}</span> |
| 814 | <span className="shrink-0 text-muted-foreground"> |
| 815 | {pullRequestCheckStatusLabel(check)} |
| 816 | </span> |
| 817 | </button> |
| 818 | {/* Only where there is something to fix. A passing check has no failure to |
| 819 | reproduce, and the button would be an invitation to waste a thread. */} |
| 820 | {onFixFinding && failing ? ( |
| 821 | <Button |
| 822 | size="xs" |
| 823 | variant="ghost" |
| 824 | className="shrink-0" |
| 825 | disabled={pendingFinding !== null && pendingFinding !== undefined} |
| 826 | onClick={() => onFixFinding(finding)} |
| 827 | > |
| 828 | <HammerIcon className="size-3" /> |
| 829 | {pendingFinding === pullRequestFindingKey(finding) |
| 830 | ? "Preparing..." |
| 831 | : fixCheckLabel} |
| 832 | </Button> |
| 833 | ) : null} |
| 834 | </div> |
| 835 | ); |
| 836 | })} |
| 837 | </div> |
| 835 | 838 | </div> |
| 836 | 839 | )} |
| 837 | | </Section> |
| 840 | </section> |
| 838 | 841 | |
| 839 | 842 | <Section |
| 840 | 843 | title="Comments" |
| 841 | | {...(activityPending || activityError ? {} : { count: detail.commentCount })} |
| 842 | 844 | actions={ |
| 843 | | !activityPending && !activityError && detail.comments.length > 0 ? ( |
| 844 | | <Button |
| 845 | | size="xs" |
| 846 | | variant="ghost" |
| 847 | | className="h-7 shrink-0 px-2 text-[10px] text-muted-foreground" |
| 848 | | aria-label={ |
| 849 | | commentOrder === "newest" |
| 850 | | ? "Show oldest comments first" |
| 851 | | : "Show newest comments first" |
| 852 | | } |
| 853 | | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} |
| 854 | | > |
| 855 | | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 856 | | {commentOrder === "newest" ? "Newest first" : "Oldest first"} |
| 857 | | </Button> |
| 858 | | ) : null |
| 845 | <Button |
| 846 | size="xs" |
| 847 | variant="ghost" |
| 848 | className="h-7 shrink-0 px-2 text-[10px] text-muted-foreground" |
| 849 | aria-label={ |
| 850 | commentOrder === "newest" |
| 851 | ? "Show oldest comments first" |
| 852 | : "Show newest comments first" |
| 853 | } |
| 854 | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} |
| 855 | > |
| 856 | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 857 | {commentOrder === "newest" ? "Newest first" : "Oldest first"} |
| 858 | </Button> |
| 859 | 859 | } |
| 860 | 860 | > |
| 861 | 861 | {activityPending ? ( |