/**
* The timeline's items (`GHPRTimelineItemComponentFactory`, `GHPRTimelineEventComponentFactoryImpl`):
* conversation comments, reviews with their status and threads, grouped commits, and the
* events with IntelliJ's texts — merged simple events as stacked paragraphs, merged state
* events as their last state.
*/
import type { ReactNode } from "react";
import { tx, txTemplate, useTx } from "@/platform/i18n";
import type {
GHActor,
GHLabel,
GHPullRequest,
GHPullRequestRequestedReviewer,
GHPullRequestReviewThread,
GHReferencedSubject,
} from "../api/types";
import { GHOST_USER } from "../data/model";
import { openInBrowser } from "../data/local";
import { deleteIssueComment, toggleReaction, updateIssueComment, updateReviewBody } from "../data/operations";
import { sameRepository, useGhRepository } from "../data/repository";
import { openPullRequest } from "../toolwindow/open";
import { BranchChip, LabelChip } from "../ui/chips";
import { Markdown } from "../ui/Markdown";
import { fullDateTime, prettyDate } from "../ui/prettyDate";
import { ChatItem, CommentItem, ThreadView } from "./comments";
import { joined, rich } from "./comments/ChatItem";
import {
canEditReview,
commitsActor,
effectiveRename,
presentableName,
reviewStatus,
threadReviewId,
type ComplexEvent,
type SimpleGroup,
type StateGroup,
type StatusType,
type TimelineCommit,
type TimelineEntry,
} from "./model";
import cstyles from "./comments/comments.module.css";
import styles from "./Timeline.module.css";
/** A web link (opens the browser). */
export function WebLink({ url, children }: { url: string; children: ReactNode }) {
return (
{
e.preventDefault();
void openInBrowser(url);
}}
>
{children}
);
}
/** `StatusMessageComponentFactory`: the content with a coloured bar on its left. */
export function StatusLine({ type = "INFO", children }: { type?: StatusType; children: ReactNode }) {
return (
);
}
const actorLink = (a: GHActor | GHPullRequestRequestedReviewer | null) => {
const r = a ?? GHOST_USER;
return {presentableName(r)};
};
// ---------------------------------------------------------------------------
// Events
function labelsText(added: GHLabel[], removed: GHLabel[]): ReactNode {
const a = added.length ? joined(added.map((l) => )) : null;
const r = removed.length ? joined(removed.map((l) => )) : null;
if (a && r) return rich(txTemplate("added labels {added} and removed labels {removed}"), { added: a, removed: r });
if (a) return rich(txTemplate("added labels {labels}"), { labels: a });
if (r) return rich(txTemplate("removed labels {labels}"), { labels: r });
return null;
}
function assigneesText(assigned: GHActor[], unassigned: GHActor[]): ReactNode {
const a = assigned.length ? joined(assigned.map(actorLink)) : null;
const u = unassigned.length ? joined(unassigned.map(actorLink)) : null;
if (a && u)
return rich(txTemplate("assigned {assigned} and unassigned {unassigned}"), { assigned: a, unassigned: u });
if (a) return rich(txTemplate("assigned {users}"), { users: a });
if (u) return rich(txTemplate("unassigned {users}"), { users: u });
return null;
}
function reviewersText(
added: Array,
removed: Array,
): ReactNode {
const a = added.length ? joined(added.map(actorLink)) : null;
const r = removed.length ? joined(removed.map(actorLink)) : null;
if (a && r)
return rich(txTemplate("requested a review from {added} and removed review request from {removed}"), {
added: a,
removed: r,
});
if (a) return rich(txTemplate("requested a review from {reviewers}"), { reviewers: a });
if (r) return rich(txTemplate("removed review request from {reviewers}"), { reviewers: r });
return null;
}
const renameText = ([from, to]: [string, string]) =>
rich(txTemplate("renamed this pull request from '{from}' to '{to}'"), { from, to });
/** An event item: the actor's row with the text in a status line. */
function EventItem({
actor,
date,
type,
children,
}: {
actor: GHActor | null;
date: string | null;
type?: StatusType;
children: ReactNode;
}) {
return (
{children}
);
}
function SimpleEventItem({ group }: { group: SimpleGroup }) {
const rename = effectiveRename(group);
const paragraphs = [
labelsText(group.addedLabels, group.removedLabels),
assigneesText(group.assigned, group.unassigned),
reviewersText(group.addedReviewers, group.removedReviewers),
rename && renameText(rename),
].filter(Boolean);
return (
{paragraphs.length === 1
? paragraphs[0]
: paragraphs.map((p, i) => (
{p}
))}
);
}
function StateEventItem({ group }: { group: StateGroup }) {
const t = useTx();
if (group.newState === "CLOSED")
return (
{t("closed this")}
);
if (group.newState === "OPEN")
return (
{t("reopened this")}
);
const merged = group.last.__typename === "MergedEvent" ? group.last : null;
const commit = merged?.mergedEventCommit;
return (
{commit && merged
? rich(txTemplate("merged commit {commit} into {branch}"), {
commit: {commit.abbreviatedOid},
branch: ,
})
: t("merged this")}
);
}
/** A referenced issue or pull request: "Title #N", the number opening it (in the IDE when it is of this repository). */
function ReferenceLink({ subject }: { subject: GHReferencedSubject }) {
const repo = useGhRepository((s) => s.connection?.coordinates ?? null);
const local = subject.__typename === "PullRequest" && sameRepository(repo, repoOfUrl(subject.url));
return (
);
}
function repoOfUrl(url: string) {
const m = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\//.exec(url);
return m ? { owner: m[1]!, name: m[2]! } : null;
}
function ComplexEventItem({ event }: { event: ComplexEvent }) {
const t = useTx();
const at = { actor: event.actor, date: event.createdAt };
const branch = (name: string) => ;
switch (event.__typename) {
case "BaseRefChangedEvent":
return {t("changed the base branch")};
case "BaseRefForcePushedEvent":
case "HeadRefForcePushedEvent":
return (
{rich(txTemplate("force-pushed the {branch} branch"), {
branch: event.ref
? branch(event.ref.name)
: event.__typename === "BaseRefForcePushedEvent"
? "base"
: "head",
})}
);
case "HeadRefDeletedEvent":
return (
{rich(txTemplate("deleted the {branch} branch"), { branch: branch(event.headRefName) })}
);
case "HeadRefRestoredEvent":
return {t("restored head branch")};
case "ReviewDismissedEvent": {
const login = (event.review?.author ?? GHOST_USER).login;
return (
{rich(txTemplate("dismissed {login}'s stale review"), { login: {login} })}
{event.dismissalMessageHTML && }
);
}
case "ReadyForReviewEvent":
return {t("marked this pull request as ready for review")};
case "ConvertToDraftEvent":
return {t("marked this pull request as draft")};
case "CrossReferencedEvent":
case "ConnectedEvent":
case "DisconnectedEvent": {
const title =
event.__typename === "CrossReferencedEvent"
? t("mentioned this pull request")
: event.__typename === "ConnectedEvent"
? t("linked an issue that may be closed by this")
: t("removed a link to an issue");
const subject = event.__typename === "CrossReferencedEvent" ? event.source : event.subject;
return (
{title}
);
}
}
}
// ---------------------------------------------------------------------------
// Commits, comments, reviews
function CommitsItem({ commits, prAuthor }: { commits: TimelineCommit[]; prAuthor: GHActor | null }) {
const t = useTx();
const n = commits.length;
const single = n === 1 ? commits[0]!.commit.author : null;
return (
{n === 1 ? t("added a commit to this pull request") : tx`added ${n} commits to this pull request`}
{commits.map(({ commit }) => {
const author = commit.author;
const actor = author ? (author.user ?? GHOST_USER) : null;
return (
{commit.abbreviatedOid} {commit.messageHeadline}
{actor && (
{actorLink(actor)}
{author?.date && (
<>
{prettyDate(author.date)}
>
)}
)}
);
})}
);
}
function ReviewItem({
number,
review,
threads,
}: {
number: number;
review: Extract["review"];
threads: GHPullRequestReviewThread[] | undefined;
}) {
const t = useTx();
const status = reviewStatus(review.state);
const own = threads?.filter((th) => threadReviewId(th) === review.id);
return (
updateReviewBody(number, review.id, body)}
footer={{t(status.text)}}
/>
{own === undefined ? (
{t("Loading…")}
) : (
own.map((th) => )
)}
);
}
/** One timeline entry. */
export function TimelineItem({
entry,
pr,
threads,
}: {
entry: TimelineEntry;
pr: GHPullRequest;
threads: GHPullRequestReviewThread[] | undefined;
}) {
const number = pr.number;
switch (entry.kind) {
case "comment": {
const c = entry.comment;
return (
updateIssueComment(number, c.id, body)}
onDelete={() => deleteIssueComment(number, c.id)}
onToggleReaction={(content, add) => toggleReaction(number, c.id, content, add, "timeline")}
/>
);
}
case "review":
return ;
case "commits":
return ;
case "simple":
return ;
case "state":
return ;
case "event":
return ;
case "unknown": {
const type = entry.type;
return (
{tx`Cannot display item - ${type}`}
);
}
}
}