/** The Pull Requests tool window: filterable list on the left, the selected pull request on the right. */
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import clsx from "clsx";
import type { GhAvailability, PrCheck, PrStateFilter, PullRequestDetails, PullRequestSummary } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { useMarkupHtml } from "@/editor/lsp/markdown";
import {
Badge,
Button,
Checkbox,
EmptyState,
Icon,
IconButton,
List,
SearchField,
Select,
SplitGroup,
SplitHandle,
SplitPanel,
Spinner,
Tabs,
showContextMenu,
type MenuItem,
T,
} from "@/ui";
import {
checksSummary,
checksText,
relativeTime,
reviewDecisionLabel,
reviewStateLabel,
reviewThreads,
stateLabel,
} from "./model";
import { addComment, checkoutPullRequest, openCommentLocation, openFileDiff, openUrl, submitReview } from "./ops";
import { cancelBusy, loadDetails, refreshAll, select, setQuery, useGithub, type DetailTab } from "./store";
import styles from "./Github.module.css";
import { t, tx } from "@/platform/i18n";
const STATES: PrStateFilter[] = ["open", "merged", "closed", "all"];
function Unavailable({ availability }: { availability: GhAvailability }) {
const retry = (
);
switch (availability.kind) {
case "notInstalled":
return (
{retry}
>
}
>
Pull requests use the gh command line tool. Install it, then run{" "}
gh auth login.
);
case "notLoggedIn":
return (
{retry}
>
}
>
Run gh auth login in a terminal, then retry.
);
case "notGitHub":
return (
This project has no remote on GitHub.
{availability.message}
);
case "error":
return (
{availability.message}
);
default:
return null;
}
}
function StateBadge({ pr }: { pr: PullRequestSummary }) {
const tone = pr.state === "merged" ? "accent" : pr.state === "closed" ? "danger" : pr.isDraft ? "neutral" : "success";
return {stateLabel(pr)};
}
function Filters() {
const query = useGithub((s) => s.query);
const loading = useGithub((s) => s.listLoading);
const [search, setSearch] = useState(query.search);
useEffect(() => {
if (search === useGithub.getState().query.search) return;
const t = setTimeout(() => setQuery({ search }), 400);
return () => clearTimeout(t);
}, [search]);
return (
);
}
function ListRow({ pr }: { pr: PullRequestSummary }) {
const decision = reviewDecisionLabel(pr.reviewDecision);
return (
{pr.title}
#{pr.number} by {pr.author} · {relativeTime(pr.updatedAt)}
{decision ? ` · ${decision}` : ""}
{pr.labels.length ? ` · ${pr.labels.map((l) => l.name).join(", ")}` : ""}
);
}
function PullRequestList() {
const list = useGithub((s) => s.list);
const error = useGithub((s) => s.listError);
const loading = useGithub((s) => s.listLoading);
const selected = useGithub((s) => s.selected);
const index = list.findIndex((p) => p.number === selected);
const menu = (pr: PullRequestSummary, e: MouseEvent) => {
e.preventDefault();
const items: MenuItem[] = [
{ label: "Checkout", icon: "branch", run: () => void checkoutPullRequest(pr.number) },
{ label: "Open in Browser", icon: "external-link", run: () => void openUrl(pr.url) },
{ label: "Copy URL", icon: "copy", run: () => void navigator.clipboard?.writeText(pr.url) },
];
showContextMenu({ x: e.clientX, y: e.clientY }, items);
};
return (
{error ? (
{error}
) : (
items={list}
getKey={(p) => String(p.number)}
rowHeight={46}
activeIndex={index}
onActiveChange={(i) => list[i] && select(list[i]!.number)}
onActivate={(p) => select(p.number)}
onContextMenu={(p, _i, e) => menu(p, e)}
renderItem={(p) => }
empty={}
aria-label="Pull requests"
className={styles.list}
/>
)}
);
}
function Markdown({ text }: { text: string }) {
const html = useMarkupHtml({ kind: "markdown", value: text });
if (!text.trim())
return (
No description provided.
);
return (
{
const a = (e.target as HTMLElement).closest("a");
if (a?.href && /^https?:/.test(a.href)) {
e.preventDefault();
void openUrl(a.href);
}
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
const CHECK_ICON: Record
= {
success: "success",
failure: "error",
pending: "clock",
cancelled: "stop",
skipped: "minus",
neutral: "info",
};
function Overview({ pr }: { pr: PullRequestDetails }) {
const summary = checksSummary(pr.checks);
return (
{tx`Checks · ${checksText(summary)}`}
{pr.checks.map((c, i) => (
{c.workflow ? `${c.workflow} / ` : ""}
{c.name}
{c.url && (
)}
))}
Reviewers
{pr.reviewers.length === 0 && (
No reviews
)}
{pr.reviewers.map((r) => (
{r.login}
{reviewStateLabel(r.state)}
))}
{(pr.summary.labels.length > 0 || pr.assignees.length > 0) && (
{pr.summary.labels.length > 0 && (
{pr.summary.labels.map((l) => (
{l.name}
))}
)}
{pr.assignees.length > 0 && {tx`Assignees: ${pr.assignees.join(", ")}`}
}
)}
);
}
function Files({ pr }: { pr: PullRequestDetails }) {
const comments = useGithub((s) => s.reviewComments);
const counts = useMemo(() => {
const m = new Map();
for (const c of comments) m.set(c.path, (m.get(c.path) ?? 0) + 1);
return m;
}, [comments]);
return (
{pr.files.map((f) => {
const slash = f.path.lastIndexOf("/");
return (
);
})}
);
}
function Commits({ pr }: { pr: PullRequestDetails }) {
return (
{pr.commits.map((c) => (
{
e.preventDefault();
showContextMenu({ x: e.clientX, y: e.clientY }, [
{ label: "Copy Revision Number", icon: "copy", run: () => void navigator.clipboard?.writeText(c.oid) },
{
label: "Open in Browser",
icon: "external-link",
run: () => void openUrl(`${pr.summary.url}/commits/${c.oid}`),
},
]);
}}
>
{c.headline}
{c.author}
{relativeTime(c.date)}
{c.oid.slice(0, 8)}
))}
);
}
function Conversation({ pr }: { pr: PullRequestDetails }) {
const comments = useGithub((s) => s.reviewComments);
const threads = useMemo(() => reviewThreads(comments), [comments]);
const [draft, setDraft] = useState("");
const [posting, setPosting] = useState(false);
return (
{pr.timeline.map((t, i) => (
{t.author}
{t.reviewState && (
{reviewStateLabel(t.reviewState)}
)}
{relativeTime(t.createdAt)}
{t.body.trim() && }
))}
{threads.length > 0 && (
Review comments
)}
{threads.map((t) => (
{t.root.line == null && (
outdated
)}
{[t.root, ...t.replies].map((c) => (
{c.author}
{relativeTime(c.createdAt)}
))}
))}
);
}
function Details() {
const pr = useGithub((s) => s.details);
const loading = useGithub((s) => s.detailsLoading);
const error = useGithub((s) => s.detailsError);
const selected = useGithub((s) => s.selected);
const tab = useGithub((s) => s.tab);
const comments = useGithub((s) => s.reviewComments);
if (!pr || pr.summary.number !== selected) {
if (error) return {error};
return loading ? (
Loading…
) : (
);
}
const s = pr.summary;
const reviewMenu = (e: MouseEvent) => {
const r = e.currentTarget.getBoundingClientRect();
showContextMenu({ x: r.left, y: r.bottom + 2 }, [
{ label: "Approve…", icon: "success", run: () => void submitReview(s.number, "approve") },
{ label: "Request Changes…", icon: "error", run: () => void submitReview(s.number, "requestChanges") },
{ label: "Comment…", icon: "edit", run: () => void submitReview(s.number, "comment") },
]);
};
return (
{s.title}
{loading && }
#{s.number} by {s.author} · {s.baseRef} ← {s.headRef}
{reviewDecisionLabel(s.reviewDecision) && (
· {reviewDecisionLabel(s.reviewDecision)}
)}
{pr.mergeable === "CONFLICTING" && (
· Conflicts
)}
void loadDetails(s.number)} />
useGithub.setState({ tab: id as DetailTab })}
aria-label="Pull request"
items={[
{ id: "overview", label: "Overview" },
{ id: "files", label: tx`Files ${pr.files.length}` },
{ id: "commits", label: tx`Commits ${pr.commits.length}` },
{ id: "conversation", label: tx`Conversation ${pr.timeline.length + comments.length}` },
]}
/>
{tab === "overview" && }
{tab === "files" && }
{tab === "commits" && }
{tab === "conversation" && }
);
}
function BusyBanner() {
const busy = useGithub((s) => s.busy);
if (!busy) return null;
return (
{busy.title}
{busy.line}
);
}
/** Below this width the list sits above the details instead of beside them. */
const SIDE_BY_SIDE_MIN = 560;
function useNarrow(el: HTMLElement | null): boolean {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(([e]) => setNarrow((e?.contentRect.width ?? Infinity) < SIDE_BY_SIDE_MIN));
ro.observe(el);
return () => ro.disconnect();
}, [el]);
return narrow;
}
export function PullRequestsView() {
const availability = useGithub((s) => s.availability);
const checking = useGithub((s) => s.checking);
const [root, setRoot] = useState(null);
const narrow = useNarrow(root);
useEffect(() => {
if (!useGithub.getState().availability) void refreshAll();
}, []);
if (!availability)
return (
{checking && } {tx("Connecting to GitHub…")}
);
if (availability.kind !== "ready")
return (
);
return (
);
}
/** Header content: the repository the list is for. */
export function PullRequestsHeader() {
const availability = useGithub((s) => s.availability);
if (availability?.kind !== "ready") return null;
const name = availability.repo.nameWithOwner;
return (
{name}
);
}