import type {
EnvironmentId,
PullRequestActor,
PullRequestComment,
PullRequestDetailView,
PullRequestRef,
} from "@t3tools/contracts";
import {
ChevronDownIcon,
ExternalLinkIcon,
FileCode2Icon,
GitCommitHorizontalIcon,
GitMergeIcon,
GitPullRequestClosedIcon,
GitPullRequestIcon,
MessageSquareIcon,
PencilIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { pullRequestEnvironment } from "~/state/pullRequests";
import { useAtomCommand } from "~/state/use-atom-command";
import { formatRelativeTimeLabel } from "~/timestampFormat";
import { Button } from "../ui/button";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
import { toastManager } from "../ui/toast";
import {
buildPullRequestTimeline,
groupPullRequestTimelineConversations,
type PullRequestTimelineEvent,
} from "./pullRequestDetail.logic";
import { canEditPullRequestComment } from "./pullRequestEditing.logic";
import { PullRequestMarkdown } from "./PullRequestMarkdown";
import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
import { PullRequestReactionBar } from "./PullRequestReactions";
import {
PullRequestActorAvatar,
PullRequestDiffStat,
PullRequestMetaLine,
} from "./pullRequestPresentation";
/** What every comment on the timeline needs to react; only the subject differs between them. */
interface ReactionSurface {
readonly canReact: boolean;
readonly environmentId: EnvironmentId;
readonly reference: PullRequestRef;
readonly onRefresh: () => void;
}
function TimelineBody({ body, markdown, cwd }: { body: string; markdown: boolean; cwd: string }) {
return (
{markdown ? (
) : (
{body}
)}
);
}
function ActorName({ actor }: { actor: PullRequestActor | null }) {
return {actor?.login ?? "ghost"};
}
function TimelineMarker({
children,
className,
}: {
children: ReactNode;
className?: string | undefined;
}) {
return (
{children}
);
}
function IconMarker({ icon, className }: { icon: ReactNode; className?: string | undefined }) {
return (
{icon}
);
}
function ActorTimelineMarker({
actors,
className,
fallback,
muted = false,
}: {
actors: ReadonlyArray;
className?: string | undefined;
fallback: ReactNode;
muted?: boolean;
}) {
const actor = actors[0];
return actor === undefined ? (
) : (
);
}
function friendlyReviewState(value: string): string {
const words = value.toLowerCase().replaceAll("_", " ").replaceAll("-", " ");
return words.replace(/^\w/u, (letter) => letter.toUpperCase());
}
function ReviewStateBadge({ state }: { state: string }) {
return (
{friendlyReviewState(state)}
);
}
function OpenOnHostButton({ url, onOpen }: { url: string | null; onOpen: (url: string) => void }) {
return url === null ? null : (
);
}
function ConversationCard({
event,
editable,
cwd,
onOpen,
reactions,
}: {
event: PullRequestTimelineEvent;
/** The remark behind this entry, only where this reader may rewrite it. */
editable: PullRequestComment | null;
cwd: string;
onOpen: (url: string) => void;
reactions: ReactionSurface;
}) {
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, {
reportFailure: false,
});
const save = async (body: string) => {
// A review's own summary is not a kind any host rewrites, which is why `editable` is never
// one; the check is here because the comment's own type still allows it.
if (editable === null || saving || editable.kind === "review") return;
setSaving(true);
const result = await updateComment({
environmentId: reactions.environmentId,
input: { ...reactions.reference, commentId: editable.id, kind: editable.kind, body },
});
setSaving(false);
if (result._tag === "Failure") {
toastManager.add({ type: "error", title: "Could not save the comment" });
return;
}
setEditing(false);
reactions.onRefresh();
};
return (
{event.title}
{event.reviewState ?
: null}
{formatRelativeTimeLabel(event.at)}
{event.path ? (
{event.path}
) : null}
{editable !== null && !editing ? (
) : null}
{editing && editable !== null ? (
void save(body)}
onCancel={() => setEditing(false)}
/>
) : event.body ? (
) : null}
{reactions.canReact || event.reactions.length > 0 ? (
) : null}
);
}
function uniqueConversationActors(events: ReadonlyArray) {
const actors = new Map();
for (const event of events) {
const actor = event.actor;
if (actor !== null && !actors.has(actor.login)) actors.set(actor.login, actor);
}
return [...actors.values()];
}
function ConversationGroup({
events,
editable,
cwd,
onOpen,
reactions,
}: {
events: ReadonlyArray;
editable: ReadonlyMap;
cwd: string;
onOpen: (url: string) => void;
reactions: ReactionSurface;
}) {
const [open, setOpen] = useState(false);
const actors = uniqueConversationActors(events);
const first = events[0];
if (first === undefined) return null;
return (
}
muted={!open}
/>
{events.length.toLocaleString()} {events.length === 1 ? "comment" : "comments"}
{actors.length.toLocaleString()} {actors.length === 1 ? "author" : "authors"} ·{" "}
{formatRelativeTimeLabel(first.at)}
{open ? (
{events.map((event) => (
))}
) : null}
);
}
function CommitEvent({
event,
onOpen,
}: {
event: PullRequestTimelineEvent;
onOpen: (oid: string) => void;
}) {
return (
);
}
function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {
const presentation =
event.kind === "opened"
? {
icon: ,
label: "Pull request opened",
}
: event.kind === "merged"
? {
icon: ,
label: "Pull request merged",
}
: {
icon: ,
label: "Pull request closed",
};
return (
{event.actor ?
: null}
{presentation.label}
{formatRelativeTimeLabel(event.at)}
);
}
export function PullRequestTimelineTab({
detail,
environmentId,
reference,
order,
onOpenCommit,
onRefresh,
}: {
detail: PullRequestDetailView;
environmentId: EnvironmentId;
reference: PullRequestRef;
order: "newest" | "oldest";
onOpenCommit: (oid: string) => void;
onRefresh: () => void;
}) {
const events = buildPullRequestTimeline(detail);
const reactions: ReactionSurface = {
canReact: detail.capabilities.reactions === true,
environmentId,
reference,
onRefresh,
};
// A timeline entry keeps only what it draws, so the remarks this reader may rewrite are looked
// up here by the id the entry carries.
const editable = new Map(
detail.comments
.filter((comment) => canEditPullRequestComment(detail, comment))
.map((comment) => [comment.id, comment] as const),
);
const orderedEvents = order === "newest" ? events : events.toReversed();
const rows = groupPullRequestTimelineConversations(orderedEvents);
const openOnHost = (url: string) => {
void readLocalApi()?.shell.openExternal(url);
};
return (
{rows.map((row) => {
if (row.kind === "comments") {
return (
);
}
const event = row.event;
if (event.kind === "commit") {
return ;
}
return ;
})}
{events.length === 0 ? (
) : null}
);
}