mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
Unify project navigation, repository controls, contextual agent chat, and compact work-item views so repository state and access recovery stay visible in one workspace. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
943 lines
33 KiB
TypeScript
943 lines
33 KiB
TypeScript
import {
|
|
Check,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
FileCode2,
|
|
GitBranch,
|
|
History,
|
|
MessageSquare,
|
|
TriangleAlert,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { DiscussedInChannels } from "./DiscussionChannels";
|
|
import { ProjectOriginReference } from "./ProjectOriginReference";
|
|
import { ForumComposer } from "@/features/forum/ui/ForumComposer";
|
|
import {
|
|
type ProjectPullRequest,
|
|
type Repository as Project,
|
|
type ProjectPullRequestCommentAnchor,
|
|
useCreateProjectPullRequestCommentMutation,
|
|
} from "@/features/projects/hooks";
|
|
import { projectPullRequestCommentTimelineKind } from "@/features/projects/projectPullRequests.mjs";
|
|
import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels";
|
|
import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks";
|
|
import {
|
|
formatExactTimestamp,
|
|
relativeTime,
|
|
} from "@/features/projects/lib/projectsViewHelpers";
|
|
import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews";
|
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
|
import { useIdentityQuery } from "@/shared/api/hooks";
|
|
import type { ChannelMember } from "@/shared/api/types";
|
|
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
|
import {
|
|
ProjectFeedRow,
|
|
ProjectFeedRowCluster,
|
|
ProjectFeedRowMonoCell,
|
|
} from "./ProjectFeedRow";
|
|
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
|
|
import { ProjectDetailSection } from "./ProjectDetailSection";
|
|
import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
|
|
import {
|
|
ProfileAuthorName,
|
|
ProfileIdentityButton,
|
|
} from "./ProjectProfileIdentity";
|
|
import { ProjectRichContent } from "./ProjectRichContent";
|
|
import { PullRequestMetaHeader } from "./PullRequestMetaRail";
|
|
import { PullRequestReviewCard } from "./PullRequestReviewCard";
|
|
import { ShareLinkButton } from "./ShareLinkButton";
|
|
import {
|
|
ProjectStatusProgressIcon,
|
|
type ProjectStatusProgressState,
|
|
} from "./ProjectStatusProgressIcon";
|
|
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
|
|
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
|
|
|
|
export { PullRequestMetaHeader } from "./PullRequestMetaRail";
|
|
|
|
function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) {
|
|
return profiles?.[normalizePubkey(pubkey)] ?? null;
|
|
}
|
|
|
|
function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) {
|
|
const profile = profileForPubkey(pubkey, profiles);
|
|
return (
|
|
profile?.displayName?.trim() ||
|
|
profile?.nip05Handle?.trim() ||
|
|
truncatePubkey(pubkey)
|
|
);
|
|
}
|
|
|
|
function pullRequestStatusClassName(status: ProjectPullRequest["status"]) {
|
|
if (status === "Closed") return "text-destructive";
|
|
if (status === "Draft") return "text-muted-foreground";
|
|
if (status === "Merged") return "text-purple-400";
|
|
return "text-green-500";
|
|
}
|
|
|
|
const PULL_REQUEST_STATUS_ORDER: readonly ProjectPullRequest["status"][] = [
|
|
"Open",
|
|
"Draft",
|
|
"Merged",
|
|
"Closed",
|
|
];
|
|
|
|
function pullRequestProgressState(
|
|
status: ProjectPullRequest["status"],
|
|
): ProjectStatusProgressState {
|
|
if (status === "Draft") return "queued";
|
|
if (status === "Merged") return "completed";
|
|
if (status === "Closed") return "canceled";
|
|
return "review";
|
|
}
|
|
|
|
function pullRequestMembers(
|
|
project: Project,
|
|
pullRequest: ProjectPullRequest,
|
|
profiles?: UserProfileLookup,
|
|
): ChannelMember[] {
|
|
return [
|
|
...new Set([
|
|
project.owner,
|
|
pullRequest.author,
|
|
...project.contributors,
|
|
...pullRequest.recipients,
|
|
]),
|
|
].map((pubkey) => {
|
|
const profile = profileForPubkey(pubkey, profiles);
|
|
return {
|
|
pubkey,
|
|
role: "member" as const,
|
|
isAgent: profile?.isAgent === true,
|
|
joinedAt: new Date(0).toISOString(),
|
|
displayName:
|
|
profile?.displayName?.trim() || profile?.nip05Handle?.trim() || null,
|
|
};
|
|
});
|
|
}
|
|
|
|
function AuthorIdentity({
|
|
avatarSize = "md",
|
|
profiles,
|
|
pubkey,
|
|
role,
|
|
showLabel = true,
|
|
}: {
|
|
avatarSize?: "xs" | "sm" | "md";
|
|
profiles?: UserProfileLookup;
|
|
pubkey: string;
|
|
role?: React.ReactNode;
|
|
showLabel?: boolean;
|
|
}) {
|
|
const profile = profileForPubkey(pubkey, profiles);
|
|
return (
|
|
<ProfileIdentityButton
|
|
align="center"
|
|
avatarSize={avatarSize}
|
|
avatarUrl={profile?.avatarUrl ?? null}
|
|
isAgent={profile?.isAgent === true}
|
|
label={labelForPubkey(pubkey, profiles)}
|
|
pubkey={pubkey}
|
|
role={role}
|
|
showLabel={showLabel}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** Commit hash chip that jumps to the commit detail when a handler is given. */
|
|
function CommitHashChip({
|
|
hash,
|
|
onOpenCommit,
|
|
}: {
|
|
hash: string;
|
|
onOpenCommit?: (commitHash: string) => void;
|
|
}) {
|
|
const short = hash.slice(0, 7);
|
|
if (!onOpenCommit) {
|
|
return (
|
|
<code className="shrink-0 rounded-md bg-background/55 px-2 py-1 text-xs text-muted-foreground">
|
|
{short}
|
|
</code>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
aria-label={`View commit ${short}`}
|
|
className="shrink-0 rounded-md bg-background/55 px-2 py-1 font-mono text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
onClick={() => onOpenCommit(hash)}
|
|
type="button"
|
|
>
|
|
{short}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PullRequestCommitRow({
|
|
author,
|
|
branch,
|
|
createdAt,
|
|
hash,
|
|
message,
|
|
onOpenCommit,
|
|
profiles,
|
|
}: {
|
|
author: string;
|
|
branch: string | null;
|
|
createdAt: number;
|
|
hash: string | null;
|
|
message: string;
|
|
onOpenCommit?: (commitHash: string) => void;
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
const authorProfile = profileForPubkey(author, profiles);
|
|
const authorLabel = labelForPubkey(author, profiles);
|
|
const openCommit =
|
|
hash && onOpenCommit ? () => onOpenCommit(hash) : undefined;
|
|
|
|
return (
|
|
<ProjectFeedRow
|
|
meta={
|
|
<>
|
|
<ProfileIdentityButton
|
|
avatarClassName="shrink-0"
|
|
avatarSize="xs"
|
|
avatarUrl={authorProfile?.avatarUrl ?? null}
|
|
isAgent={authorProfile?.isAgent === true}
|
|
label={authorLabel}
|
|
pubkey={author}
|
|
showLabel={false}
|
|
/>
|
|
<span className="truncate">
|
|
<ProfileAuthorName pubkey={author}>{authorLabel}</ProfileAuthorName>{" "}
|
|
authored
|
|
</span>
|
|
{branch ? (
|
|
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/60 px-1.5 py-0.5 font-mono text-2xs">
|
|
<GitBranch className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{branch}</span>
|
|
</span>
|
|
) : null}
|
|
</>
|
|
}
|
|
onOpen={openCommit}
|
|
testId="project-pull-request-commit-row"
|
|
title={message}
|
|
trailing={
|
|
<>
|
|
{hash ? (
|
|
<ProjectFeedRowCluster>
|
|
<ProjectFeedRowMonoCell
|
|
label={hash.slice(0, 7)}
|
|
onClick={openCommit}
|
|
title={`View commit ${hash.slice(0, 7)}`}
|
|
/>
|
|
<CopyCommitHashButton hash={hash} />
|
|
</ProjectFeedRowCluster>
|
|
) : null}
|
|
<span
|
|
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
|
|
data-testid="project-pull-request-commit-row-date"
|
|
title={formatExactTimestamp(createdAt)}
|
|
>
|
|
{relativeTime(createdAt)}
|
|
</span>
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function PullRequestRow({
|
|
onOpen,
|
|
profiles,
|
|
pullRequest,
|
|
}: {
|
|
onOpen: () => void;
|
|
profiles?: UserProfileLookup;
|
|
pullRequest: ProjectPullRequest;
|
|
}) {
|
|
const authorProfile = profileForPubkey(pullRequest.author, profiles);
|
|
const authorLabel = labelForPubkey(pullRequest.author, profiles);
|
|
const statusClassName = pullRequestStatusClassName(pullRequest.status);
|
|
|
|
return (
|
|
<ProjectWorkItemRow
|
|
eventId={pullRequest.id}
|
|
identifier={`#${pullRequest.id.slice(0, 8)}`}
|
|
identifierTitle="View review"
|
|
metadata={
|
|
pullRequest.branchName ? (
|
|
<span className="inline-flex min-w-0 items-center gap-1">
|
|
<GitBranch className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{pullRequest.branchName}</span>
|
|
</span>
|
|
) : undefined
|
|
}
|
|
onOpen={onOpen}
|
|
statusIcon={
|
|
<ProjectStatusProgressIcon
|
|
aria-label={pullRequest.status}
|
|
className={`h-3.5 w-3.5 shrink-0 ${statusClassName}`}
|
|
state={pullRequestProgressState(pullRequest.status)}
|
|
/>
|
|
}
|
|
testId="project-pull-request-row"
|
|
title={pullRequest.title}
|
|
trailing={
|
|
<>
|
|
<span className="flex w-8 shrink-0 justify-end">
|
|
<button
|
|
aria-label={
|
|
pullRequest.comments.length > 0
|
|
? `View ${pullRequest.comments.length} comments`
|
|
: "View comments"
|
|
}
|
|
className={`flex items-center gap-1 rounded-md text-xs hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
|
|
pullRequest.comments.length > 0
|
|
? "text-muted-foreground"
|
|
: "text-muted-foreground/45"
|
|
}`}
|
|
data-testid="project-pull-request-comments"
|
|
onClick={onOpen}
|
|
type="button"
|
|
>
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
{pullRequest.comments.length}
|
|
</button>
|
|
</span>
|
|
<ProfileIdentityButton
|
|
avatarClassName="shrink-0"
|
|
avatarSize="xs"
|
|
avatarUrl={authorProfile?.avatarUrl ?? null}
|
|
isAgent={authorProfile?.isAgent === true}
|
|
label={authorLabel}
|
|
pubkey={pullRequest.author}
|
|
showLabel={false}
|
|
/>
|
|
<span
|
|
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground/70 sm:block"
|
|
data-testid="project-pull-request-row-date"
|
|
title={formatExactTimestamp(pullRequest.createdAt)}
|
|
>
|
|
{relativeTime(pullRequest.createdAt)}
|
|
</span>
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** GitHub-style PR title line, rendered as the top of the review detail
|
|
* card. Status, branches, and dates sit in the meta header below. */
|
|
export function PullRequestDetailHeader({
|
|
profiles,
|
|
pullRequest,
|
|
}: {
|
|
profiles?: UserProfileLookup;
|
|
pullRequest: ProjectPullRequest;
|
|
}) {
|
|
const authorLabel = labelForPubkey(pullRequest.author, profiles);
|
|
|
|
return (
|
|
<header className="min-w-0 space-y-1 px-4 pb-1 pt-3">
|
|
<h3 className="line-clamp-2 min-w-0 text-xl font-semibold text-foreground">
|
|
{pullRequest.title}{" "}
|
|
<span className="font-normal text-muted-foreground">
|
|
#{pullRequest.id.slice(0, 8)}
|
|
</span>
|
|
<ShareLinkButton
|
|
className="ml-1 inline-flex h-7 w-7 align-text-bottom"
|
|
label="Copy review link"
|
|
link={pullRequestShareLink(pullRequest)}
|
|
testId="project-pull-request-copy-link"
|
|
/>
|
|
</h3>
|
|
<p
|
|
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs font-medium text-muted-foreground"
|
|
data-testid="project-pull-request-detail-metadata"
|
|
>
|
|
<span
|
|
className="flex min-w-0 items-center gap-1"
|
|
data-project-metadata-phrase
|
|
>
|
|
<AuthorIdentity
|
|
avatarSize="xs"
|
|
profiles={profiles}
|
|
pubkey={pullRequest.author}
|
|
showLabel={false}
|
|
/>
|
|
<ProfileAuthorName pubkey={pullRequest.author}>
|
|
{authorLabel}
|
|
</ProfileAuthorName>
|
|
</span>
|
|
<span
|
|
className="shrink-0 whitespace-nowrap"
|
|
data-project-metadata-phrase
|
|
title={formatExactTimestamp(pullRequest.createdAt)}
|
|
>
|
|
{relativeTime(pullRequest.createdAt)}
|
|
</span>
|
|
<ProjectOriginReference
|
|
agentName={pullRequest.originAgentName}
|
|
channelId={pullRequest.channelId}
|
|
/>
|
|
</p>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/** Full pull-request conversation, review actions, and comment composer. */
|
|
export function ProjectPullRequestDetail({
|
|
diffStats,
|
|
filesChanged,
|
|
filesCount,
|
|
forceOpenFiles = false,
|
|
onOpenInlineComment,
|
|
onOpenCommit,
|
|
onOpenTerminal,
|
|
profiles,
|
|
project,
|
|
pullRequest,
|
|
}: {
|
|
diffStats?: { additions: number; deletions: number } | null;
|
|
filesChanged?: React.ReactNode;
|
|
filesCount?: number;
|
|
forceOpenFiles?: boolean;
|
|
onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void;
|
|
onOpenCommit?: (commitHash: string) => void;
|
|
onOpenTerminal?: OpenMergeRecoveryTerminal;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
}) {
|
|
const identityQuery = useIdentityQuery();
|
|
const commentMutation = useCreateProjectPullRequestCommentMutation(project);
|
|
const [
|
|
expandedReviewHistoryPullRequestIds,
|
|
setExpandedReviewHistoryPullRequestIds,
|
|
] = React.useState<Set<string>>(() => new Set());
|
|
const [
|
|
collapsedReviewHistoryPullRequestIds,
|
|
setCollapsedReviewHistoryPullRequestIds,
|
|
] = React.useState<Set<string>>(() => new Set());
|
|
const [filesOpen, setFilesOpen] = React.useState(forceOpenFiles);
|
|
const [filesOpenForId, setFilesOpenForId] = React.useState(pullRequest.id);
|
|
if (filesOpenForId !== pullRequest.id) {
|
|
setFilesOpenForId(pullRequest.id);
|
|
setFilesOpen(forceOpenFiles);
|
|
}
|
|
const members = React.useMemo(
|
|
() => pullRequestMembers(project, pullRequest, profiles),
|
|
[profiles, project, pullRequest],
|
|
);
|
|
React.useEffect(() => {
|
|
if (forceOpenFiles) setFilesOpen(true);
|
|
}, [forceOpenFiles]);
|
|
const submitComment = React.useCallback(
|
|
async (
|
|
content: string,
|
|
mentionPubkeys: string[],
|
|
mediaTags?: string[][],
|
|
decision?: "request-changes",
|
|
) => {
|
|
try {
|
|
await commentMutation.mutateAsync({
|
|
content,
|
|
decision,
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
pullRequest,
|
|
});
|
|
toast.success(
|
|
decision === "request-changes"
|
|
? "Changes requested."
|
|
: "Comment posted.",
|
|
);
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to post comment.",
|
|
);
|
|
throw error;
|
|
}
|
|
},
|
|
[commentMutation, pullRequest],
|
|
);
|
|
const handleCommentSubmit = React.useCallback(
|
|
(content: string, mentionPubkeys: string[], mediaTags?: string[][]) =>
|
|
submitComment(content, mentionPubkeys, mediaTags),
|
|
[submitComment],
|
|
);
|
|
const handleChangeRequestSubmit = React.useCallback(
|
|
(content: string, mentionPubkeys: string[], mediaTags?: string[][]) =>
|
|
submitComment(content, mentionPubkeys, mediaTags, "request-changes"),
|
|
[submitComment],
|
|
);
|
|
|
|
const commitCount = Math.max(1, pullRequest.updates.length + 1);
|
|
const reviewHistory = pullRequest.comments
|
|
.map((item) => ({
|
|
item,
|
|
timelineKind: projectPullRequestCommentTimelineKind(item),
|
|
}))
|
|
.sort(
|
|
(left, right) =>
|
|
left.item.createdAt - right.item.createdAt ||
|
|
left.item.id.localeCompare(right.item.id),
|
|
);
|
|
const reviewHistoryCollapsed = collapsedReviewHistoryPullRequestIds.has(
|
|
pullRequest.id,
|
|
);
|
|
const reviewHistoryExpanded = expandedReviewHistoryPullRequestIds.has(
|
|
pullRequest.id,
|
|
);
|
|
const earlierReviewHistoryCount = Math.max(0, reviewHistory.length - 3);
|
|
const visibleReviewHistory =
|
|
reviewHistoryExpanded || earlierReviewHistoryCount === 0
|
|
? reviewHistory
|
|
: reviewHistory.slice(-3);
|
|
const displayedReviewHistory = reviewHistoryCollapsed
|
|
? []
|
|
: visibleReviewHistory;
|
|
const canRequestChanges = canReviewProjectPullRequest(
|
|
project,
|
|
pullRequest,
|
|
identityQuery.data?.pubkey,
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<PullRequestDetailHeader profiles={profiles} pullRequest={pullRequest} />
|
|
<PullRequestMetaHeader
|
|
diffStats={diffStats}
|
|
profiles={profiles}
|
|
project={project}
|
|
pullRequest={pullRequest}
|
|
/>
|
|
{pullRequest.content || pullRequest.updates.length > 0 ? (
|
|
<ProjectDetailSection defaultOpen title="Description">
|
|
{pullRequest.content ? (
|
|
<ProjectRichContent
|
|
content={pullRequest.content}
|
|
tags={pullRequest.tags}
|
|
/>
|
|
) : null}
|
|
{pullRequest.updates.length > 0 ? (
|
|
<div
|
|
className={pullRequest.content ? "mt-4 space-y-3" : "space-y-3"}
|
|
>
|
|
<h4 className="text-sm font-semibold text-foreground">Updates</h4>
|
|
{pullRequest.updates.map((update) => (
|
|
<article className="space-y-1" key={update.id}>
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
<AuthorIdentity
|
|
profiles={profiles}
|
|
pubkey={update.author}
|
|
role={
|
|
<span title={formatExactTimestamp(update.createdAt)}>
|
|
{relativeTime(update.createdAt)}
|
|
</span>
|
|
}
|
|
/>
|
|
{update.commit ? (
|
|
<CommitHashChip
|
|
hash={update.commit}
|
|
onOpenCommit={onOpenCommit}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{update.content ? (
|
|
<ProjectRichContent
|
|
className="text-sm text-muted-foreground"
|
|
content={update.content}
|
|
tags={update.tags}
|
|
/>
|
|
) : null}
|
|
</article>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</ProjectDetailSection>
|
|
) : null}
|
|
{filesChanged ? (
|
|
<ProjectDetailSection
|
|
count={filesCount}
|
|
defaultOpen={false}
|
|
onOpenChange={setFilesOpen}
|
|
open={filesOpen}
|
|
testId="project-detail-section-files"
|
|
title="Files changed"
|
|
>
|
|
<div className="-mx-4">{filesChanged}</div>
|
|
</ProjectDetailSection>
|
|
) : null}
|
|
<ProjectDetailSection
|
|
count={commitCount}
|
|
defaultOpen={false}
|
|
title="Commits"
|
|
>
|
|
<div className="-mx-4 divide-y divide-border/50">
|
|
<PullRequestCommitRow
|
|
author={pullRequest.author}
|
|
branch={pullRequest.branchName}
|
|
createdAt={pullRequest.createdAt}
|
|
hash={pullRequest.commit}
|
|
message={pullRequest.title}
|
|
onOpenCommit={onOpenCommit}
|
|
profiles={profiles}
|
|
/>
|
|
{pullRequest.updates.map((update) => (
|
|
<PullRequestCommitRow
|
|
author={update.author}
|
|
branch={pullRequest.branchName}
|
|
createdAt={update.createdAt}
|
|
hash={update.commit}
|
|
key={update.id}
|
|
message={update.content.trim() || "Updated review branch"}
|
|
onOpenCommit={onOpenCommit}
|
|
profiles={profiles}
|
|
/>
|
|
))}
|
|
</div>
|
|
</ProjectDetailSection>
|
|
<ProjectDetailSection defaultOpen={false} title="Checks">
|
|
<p className="text-sm text-muted-foreground">
|
|
No checks have been reported for this review yet.
|
|
</p>
|
|
</ProjectDetailSection>
|
|
<ProjectDetailSection defaultOpen title="Activity">
|
|
<div className="space-y-3">
|
|
<DiscussedInChannels
|
|
entityLabel="this review"
|
|
originChannelId={pullRequest.channelId}
|
|
originCreatedAt={pullRequest.createdAt}
|
|
originPubkey={pullRequest.author}
|
|
query={entityDiscussionQuery(pullRequest.id)}
|
|
testId="pull-request-discussed-in"
|
|
/>
|
|
<div className="group/timeline -mx-4 overflow-hidden">
|
|
{reviewHistory.length > 0 ? (
|
|
<button
|
|
aria-expanded={!reviewHistoryCollapsed}
|
|
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
data-testid="project-pull-request-review-history-toggle"
|
|
onClick={() => {
|
|
setCollapsedReviewHistoryPullRequestIds((current) => {
|
|
const next = new Set(current);
|
|
if (reviewHistoryCollapsed) {
|
|
next.delete(pullRequest.id);
|
|
} else {
|
|
next.add(pullRequest.id);
|
|
}
|
|
return next;
|
|
});
|
|
}}
|
|
type="button"
|
|
>
|
|
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
|
|
{reviewHistoryCollapsed ? (
|
|
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
|
|
) : (
|
|
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
|
|
)}
|
|
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/35">
|
|
<History className="h-3 w-3" />
|
|
</span>
|
|
</span>
|
|
<span className="flex min-h-5 min-w-0 flex-1 items-center text-left">
|
|
{reviewHistoryCollapsed
|
|
? `Show ${reviewHistory.length} earlier ${
|
|
reviewHistory.length === 1 ? "activity" : "activities"
|
|
}`
|
|
: "Collapse review history"}
|
|
</span>
|
|
{reviewHistoryCollapsed ? (
|
|
<ChevronDown className="mt-0.5 h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronUp className="mt-0.5 h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
) : null}
|
|
{!reviewHistoryCollapsed &&
|
|
earlierReviewHistoryCount > 0 &&
|
|
!reviewHistoryExpanded ? (
|
|
<button
|
|
className="flex min-h-10 w-full items-center gap-2 px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
data-testid="project-pull-request-earlier-activities"
|
|
onClick={() => {
|
|
setExpandedReviewHistoryPullRequestIds((current) => {
|
|
const next = new Set(current);
|
|
next.add(pullRequest.id);
|
|
return next;
|
|
});
|
|
}}
|
|
type="button"
|
|
>
|
|
<span className="relative flex w-5 shrink-0 justify-center self-stretch">
|
|
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
|
|
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
|
|
<ChevronDown className="h-3 w-3" />
|
|
</span>
|
|
</span>
|
|
<span className="min-w-0 flex-1 text-left">
|
|
Show {earlierReviewHistoryCount} earlier{" "}
|
|
{earlierReviewHistoryCount === 1 ? "activity" : "activities"}
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
{displayedReviewHistory.map(({ item, timelineKind }, index) => {
|
|
const isHistoricalDecision =
|
|
item.reviewDecisionStatus === "historical";
|
|
const trimmedContent = item.content.trim();
|
|
const activityContent =
|
|
timelineKind === null
|
|
? trimmedContent
|
|
: timelineKind === "changes-requested" &&
|
|
!/^requested changes\.?$/i.test(trimmedContent)
|
|
? trimmedContent
|
|
: timelineKind === "approved" &&
|
|
!/^approved (these )?changes\.?$/i.test(trimmedContent)
|
|
? trimmedContent
|
|
: null;
|
|
return (
|
|
<div
|
|
className="flex min-h-10 min-w-0 items-start gap-2 px-3 py-2.5 text-sm text-muted-foreground"
|
|
data-testid="project-pull-request-timeline-row"
|
|
key={item.id}
|
|
>
|
|
<div className="relative flex w-5 shrink-0 justify-center self-stretch">
|
|
{index < displayedReviewHistory.length - 1 ? (
|
|
<span className="absolute top-2.5 -bottom-[1.875rem] w-px bg-border/80" />
|
|
) : (
|
|
<span className="absolute top-2.5 -bottom-11 hidden w-px bg-border/80 group-has-[.pull-request-action-timeline]/timeline:block" />
|
|
)}
|
|
<span className="relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border/70">
|
|
{timelineKind === "approved" ? (
|
|
<Check
|
|
className={`h-3 w-3 ${
|
|
isHistoricalDecision
|
|
? "text-muted-foreground"
|
|
: "text-green-600 dark:text-green-500"
|
|
}`}
|
|
/>
|
|
) : timelineKind === "changes-requested" ? (
|
|
<TriangleAlert
|
|
className={`h-3 w-3 ${
|
|
isHistoricalDecision
|
|
? "text-muted-foreground"
|
|
: "text-amber-600 dark:text-amber-400"
|
|
}`}
|
|
/>
|
|
) : timelineKind === "review-request" ? (
|
|
<UserPlus className="h-3 w-3" />
|
|
) : (
|
|
<MessageSquare className="h-3 w-3" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center">
|
|
<span className="min-w-0 truncate">
|
|
<ProfileAuthorName pubkey={item.author}>
|
|
{labelForPubkey(item.author, profiles)}
|
|
</ProfileAuthorName>
|
|
{timelineKind ? (
|
|
<>
|
|
{" "}
|
|
{timelineKind === "approved"
|
|
? isHistoricalDecision
|
|
? "approved an earlier commit"
|
|
: "approved these changes"
|
|
: timelineKind === "changes-requested"
|
|
? isHistoricalDecision
|
|
? "requested changes on an earlier commit"
|
|
: "requested changes"
|
|
: trimmedContent || "requested a review"}
|
|
</>
|
|
) : null}
|
|
</span>
|
|
<span
|
|
className="ml-auto w-20 shrink-0 text-right text-xs text-muted-foreground/70"
|
|
title={formatExactTimestamp(item.createdAt)}
|
|
>
|
|
{relativeTime(item.createdAt)}
|
|
</span>
|
|
</div>
|
|
{activityContent ? (
|
|
<ProjectRichContent
|
|
className="mt-1 text-sm text-foreground/90"
|
|
content={activityContent}
|
|
tags={item.tags}
|
|
/>
|
|
) : null}
|
|
{item.anchor ? (
|
|
<button
|
|
aria-label={`Open ${item.anchor.path} ${item.anchor.side} line ${item.anchor.line} in Files changed`}
|
|
className="mt-1 inline-flex min-w-0 items-center gap-1 rounded-md bg-muted/65 px-1.5 py-0.5 font-mono text-2xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
onClick={() => {
|
|
if (item.anchor) onOpenInlineComment?.(item.anchor);
|
|
}}
|
|
type="button"
|
|
>
|
|
<FileCode2 className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{item.anchor.path}</span>
|
|
<span className="shrink-0">
|
|
{item.anchor.side === "new" ? "+" : "-"}
|
|
{item.anchor.line}
|
|
</span>
|
|
{item.inlineCommentStatus === "outdated" ? (
|
|
<span className="shrink-0 text-destructive">
|
|
Outdated
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="flex min-h-12 items-start justify-start px-3 py-2.5">
|
|
<PullRequestReviewCard
|
|
onOpenTerminal={onOpenTerminal}
|
|
project={project}
|
|
pullRequest={pullRequest}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ProjectDetailSection>
|
|
<div
|
|
className="px-4 pb-4 pt-2"
|
|
data-testid="project-pull-request-comment-composer"
|
|
>
|
|
<ForumComposer
|
|
className="border border-border/60 bg-background/45"
|
|
disabled={commentMutation.isPending}
|
|
isSending={commentMutation.isPending}
|
|
members={members}
|
|
onSecondarySubmit={
|
|
canRequestChanges ? handleChangeRequestSubmit : undefined
|
|
}
|
|
onSubmit={handleCommentSubmit}
|
|
placeholder="Add a comment…"
|
|
profiles={profiles}
|
|
secondarySubmitLabel="Request changes"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PullRequestsPanel({
|
|
diffStats,
|
|
error,
|
|
filesChanged,
|
|
filesCount,
|
|
forceOpenFiles,
|
|
isLoading,
|
|
onOpenInlineComment,
|
|
onOpenCommit,
|
|
onOpenTerminal,
|
|
onSelectedPullRequestIdChange,
|
|
profiles,
|
|
project,
|
|
pullRequests,
|
|
selectedPullRequestId,
|
|
}: {
|
|
diffStats?: { additions: number; deletions: number } | null;
|
|
error: unknown;
|
|
filesChanged?: React.ReactNode;
|
|
filesCount?: number;
|
|
forceOpenFiles?: boolean;
|
|
isLoading: boolean;
|
|
onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void;
|
|
onOpenCommit?: (commitHash: string) => void;
|
|
onOpenTerminal?: OpenMergeRecoveryTerminal;
|
|
onSelectedPullRequestIdChange: (id: string | null) => void;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
pullRequests: ProjectPullRequest[];
|
|
selectedPullRequestId: string | null;
|
|
}) {
|
|
const selectedPullRequest =
|
|
pullRequests.find((item) => item.id === selectedPullRequestId) ?? null;
|
|
|
|
React.useEffect(() => {
|
|
if (
|
|
selectedPullRequestId &&
|
|
!pullRequests.some((item) => item.id === selectedPullRequestId)
|
|
) {
|
|
onSelectedPullRequestIdChange(null);
|
|
}
|
|
}, [onSelectedPullRequestIdChange, pullRequests, selectedPullRequestId]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<p className="p-4 text-sm text-muted-foreground">Loading reviews…</p>
|
|
);
|
|
}
|
|
|
|
if (pullRequests.length === 0) {
|
|
return (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
{error
|
|
? "Could not load reviews for this repository."
|
|
: "No reviews yet."}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
if (selectedPullRequest) {
|
|
return (
|
|
<ProjectPullRequestDetail
|
|
diffStats={diffStats}
|
|
filesChanged={filesChanged}
|
|
filesCount={filesCount}
|
|
forceOpenFiles={forceOpenFiles}
|
|
onOpenInlineComment={onOpenInlineComment}
|
|
onOpenCommit={onOpenCommit}
|
|
onOpenTerminal={onOpenTerminal}
|
|
profiles={profiles}
|
|
project={project}
|
|
pullRequest={selectedPullRequest}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const groups = PULL_REQUEST_STATUS_ORDER.map((status) => ({
|
|
items: pullRequests.filter((pullRequest) => pullRequest.status === status),
|
|
status,
|
|
})).filter((group) => group.items.length > 0);
|
|
|
|
return (
|
|
<div>
|
|
{groups.map(({ items, status }) => {
|
|
return (
|
|
<ProjectWorkItemGroup
|
|
count={items.length}
|
|
icon={
|
|
<ProjectStatusProgressIcon
|
|
className={`h-4 w-4 ${pullRequestStatusClassName(status)}`}
|
|
state={pullRequestProgressState(status)}
|
|
/>
|
|
}
|
|
key={status}
|
|
label={status}
|
|
>
|
|
{items.map((pullRequest) => (
|
|
<PullRequestRow
|
|
key={pullRequest.id}
|
|
onOpen={() => onSelectedPullRequestIdChange(pullRequest.id)}
|
|
profiles={profiles}
|
|
pullRequest={pullRequest}
|
|
/>
|
|
))}
|
|
</ProjectWorkItemGroup>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|