дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
коміт
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -0,0 +1,367 @@
|
||||
.view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.flex {
|
||||
flex: 1;
|
||||
}
|
||||
.dim {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.ellipsis {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repo {
|
||||
margin-left: 8px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.banner {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 2px 6px 2px 10px;
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.bannerText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* List pane */
|
||||
.listPane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.filters {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
.filterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
min-height: var(--h-control);
|
||||
}
|
||||
.filterRow > :not(.flex) {
|
||||
flex: none;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.listRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.listTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.listMeta {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Details pane */
|
||||
.details {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-input-large);
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.conflict {
|
||||
color: var(--danger);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.actions > :not(.flex) {
|
||||
flex: none;
|
||||
}
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.section {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sectionTitle {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.checkRow,
|
||||
.commitRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--h-row);
|
||||
}
|
||||
.check-success,
|
||||
.review-APPROVED {
|
||||
color: var(--success);
|
||||
}
|
||||
.check-failure,
|
||||
.review-CHANGES_REQUESTED {
|
||||
color: var(--danger);
|
||||
}
|
||||
.check-pending,
|
||||
.review-REQUESTED {
|
||||
color: var(--warning);
|
||||
}
|
||||
.check-skipped,
|
||||
.check-cancelled,
|
||||
.check-neutral,
|
||||
.review-COMMENTED,
|
||||
.review-DISMISSED {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.label {
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-small);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fileRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: var(--h-row);
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
border-radius: var(--radius-row);
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.fileRow:hover {
|
||||
background: var(--bg-tree-hover);
|
||||
}
|
||||
.fileName {
|
||||
flex: none;
|
||||
}
|
||||
.change-ADDED {
|
||||
color: var(--vcs-added);
|
||||
}
|
||||
.change-DELETED {
|
||||
color: var(--vcs-deleted);
|
||||
}
|
||||
.change-MODIFIED,
|
||||
.change-CHANGED,
|
||||
.change-RENAMED,
|
||||
.change-COPIED {
|
||||
color: var(--vcs-modified);
|
||||
}
|
||||
.added {
|
||||
color: var(--vcs-added);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.deleted {
|
||||
color: var(--vcs-deleted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.oid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.comment {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
.commentHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.threadItem {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.threadItem + .threadItem {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.newComment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.markdown {
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.markdown :global(pre) {
|
||||
padding: 6px 8px;
|
||||
overflow: auto;
|
||||
background: var(--bg-editor);
|
||||
border-radius: var(--radius-small);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.markdown :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.markdown :global(a) {
|
||||
color: var(--link);
|
||||
}
|
||||
.markdown :global(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
.markdown :global(h1),
|
||||
.markdown :global(h2),
|
||||
.markdown :global(h3) {
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.formRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.fieldLabel {
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 48px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/** 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,
|
||||
} 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";
|
||||
|
||||
const STATES: Array<{ value: PrStateFilter; label: string }> = [
|
||||
{ value: "open", label: "Open" },
|
||||
{ value: "merged", label: "Merged" },
|
||||
{ value: "closed", label: "Closed" },
|
||||
{ value: "all", label: "All" },
|
||||
];
|
||||
|
||||
function Unavailable({ availability }: { availability: GhAvailability }) {
|
||||
const retry = <Button size="small" onClick={() => void refreshAll()}>Retry</Button>;
|
||||
switch (availability.kind) {
|
||||
case "notInstalled":
|
||||
return (
|
||||
<EmptyState
|
||||
title="GitHub CLI is not installed"
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" onClick={() => void openUrl("https://cli.github.com")}>Install gh…</Button>
|
||||
{retry}
|
||||
</>
|
||||
}
|
||||
>
|
||||
Pull requests use the <code>gh</code> command line tool. Install it, then run <code>gh auth login</code>.
|
||||
</EmptyState>
|
||||
);
|
||||
case "notLoggedIn":
|
||||
return (
|
||||
<EmptyState
|
||||
title="Not logged in to GitHub"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={() => void executeAction("terminal.runCommand", { program: "gh", args: ["auth", "login"], name: "gh auth login" })}
|
||||
>
|
||||
Run gh auth login
|
||||
</Button>
|
||||
{retry}
|
||||
</>
|
||||
}
|
||||
>
|
||||
Run <code>gh auth login</code> in a terminal, then retry.
|
||||
</EmptyState>
|
||||
);
|
||||
case "notGitHub":
|
||||
return (
|
||||
<EmptyState title="No GitHub repository" actions={retry}>
|
||||
<div>This project has no remote on GitHub.</div>
|
||||
<div className={styles.hint}>{availability.message}</div>
|
||||
</EmptyState>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<EmptyState title="Could not reach GitHub" actions={retry}>
|
||||
{availability.message}
|
||||
</EmptyState>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function StateBadge({ pr }: { pr: PullRequestSummary }) {
|
||||
const tone = pr.state === "merged" ? "accent" : pr.state === "closed" ? "danger" : pr.isDraft ? "neutral" : "success";
|
||||
return <Badge tone={tone}>{stateLabel(pr)}</Badge>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.filters}>
|
||||
<SearchField value={search} onChange={setSearch} placeholder="Search (GitHub syntax)" aria-label="Search pull requests" className={styles.search} />
|
||||
<div className={styles.filterRow}>
|
||||
<Select value={query.state} options={STATES} onChange={(state) => setQuery({ state })} aria-label="State" />
|
||||
<Checkbox checked={query.authorMe} onChange={(authorMe) => setQuery({ authorMe })} label="Mine" />
|
||||
<Checkbox checked={query.reviewRequested} onChange={(reviewRequested) => setQuery({ reviewRequested })} label="Review requested" />
|
||||
<span className={styles.flex} />
|
||||
{loading ? <Spinner size={14} /> : <IconButton icon="refresh" size="small" action="github.refresh" />}
|
||||
<IconButton icon="plus" size="small" action="github.createPullRequest" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListRow({ pr }: { pr: PullRequestSummary }) {
|
||||
const decision = reviewDecisionLabel(pr.reviewDecision);
|
||||
return (
|
||||
<div className={styles.listRow}>
|
||||
<div className={styles.listTitle}>
|
||||
<span className={styles.ellipsis}>{pr.title}</span>
|
||||
<StateBadge pr={pr} />
|
||||
</div>
|
||||
<div className={styles.listMeta}>
|
||||
#{pr.number} by {pr.author} · {relativeTime(pr.updatedAt)}
|
||||
{decision ? ` · ${decision}` : ""}
|
||||
{pr.labels.length ? ` · ${pr.labels.map((l) => l.name).join(", ")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.listPane}>
|
||||
<Filters />
|
||||
{error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : (
|
||||
<List<PullRequestSummary>
|
||||
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) => <ListRow pr={p} />}
|
||||
empty={<EmptyState title={loading ? "Loading pull requests…" : "No pull requests"} />}
|
||||
aria-label="Pull requests"
|
||||
className={styles.list}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Markdown({ text }: { text: string }) {
|
||||
const html = useMarkupHtml({ kind: "markdown", value: text });
|
||||
if (!text.trim()) return <div className={styles.dim}>No description provided.</div>;
|
||||
return (
|
||||
<div
|
||||
className={styles.markdown}
|
||||
onClick={(e) => {
|
||||
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<PrCheck["status"], string> = {
|
||||
success: "success",
|
||||
failure: "error",
|
||||
pending: "clock",
|
||||
cancelled: "stop",
|
||||
skipped: "minus",
|
||||
neutral: "info",
|
||||
};
|
||||
|
||||
function Overview({ pr }: { pr: PullRequestDetails }) {
|
||||
const summary = checksSummary(pr.checks);
|
||||
return (
|
||||
<div className={styles.scroll}>
|
||||
<Markdown text={pr.body} />
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Checks · {checksText(summary)}</h3>
|
||||
{pr.checks.map((c, i) => (
|
||||
<div key={i} className={styles.checkRow}>
|
||||
<Icon name={CHECK_ICON[c.status]} size={14} className={styles[`check-${c.status}`]} />
|
||||
<span className={styles.ellipsis}>
|
||||
{c.workflow ? `${c.workflow} / ` : ""}
|
||||
{c.name}
|
||||
</span>
|
||||
{c.url && (
|
||||
<button type="button" className={styles.link} onClick={() => void openUrl(c.url!)}>
|
||||
Details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Reviewers</h3>
|
||||
{pr.reviewers.length === 0 && <div className={styles.dim}>No reviews</div>}
|
||||
{pr.reviewers.map((r) => (
|
||||
<div key={r.login} className={styles.checkRow}>
|
||||
<Icon
|
||||
name={r.state === "APPROVED" ? "success" : r.state === "CHANGES_REQUESTED" ? "error" : r.state === "REQUESTED" ? "clock" : "info"}
|
||||
size={14}
|
||||
className={styles[`review-${r.state}`]}
|
||||
/>
|
||||
<span>{r.login}</span>
|
||||
<span className={styles.dim}>{reviewStateLabel(r.state)}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{(pr.summary.labels.length > 0 || pr.assignees.length > 0) && (
|
||||
<section className={styles.section}>
|
||||
{pr.summary.labels.length > 0 && (
|
||||
<div className={styles.labels}>
|
||||
{pr.summary.labels.map((l) => (
|
||||
<span key={l.name} className={styles.label}>
|
||||
{l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pr.assignees.length > 0 && <div className={styles.dim}>Assignees: {pr.assignees.join(", ")}</div>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Files({ pr }: { pr: PullRequestDetails }) {
|
||||
const comments = useGithub((s) => s.reviewComments);
|
||||
const counts = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const c of comments) m.set(c.path, (m.get(c.path) ?? 0) + 1);
|
||||
return m;
|
||||
}, [comments]);
|
||||
return (
|
||||
<div className={styles.scroll} role="list" aria-label="Changed files">
|
||||
{pr.files.map((f) => {
|
||||
const slash = f.path.lastIndexOf("/");
|
||||
return (
|
||||
<button key={f.path} type="button" role="listitem" className={styles.fileRow} onClick={(e) => e.detail <= 1 && void openFileDiff(pr, f)} title={f.path}>
|
||||
<Icon name="diff" size={14} className={styles[`change-${f.changeType}`]} />
|
||||
<span className={clsx(styles.fileName, styles[`change-${f.changeType}`])}>{f.path.slice(slash + 1)}</span>
|
||||
<span className={clsx(styles.dim, styles.ellipsis)}>{slash > 0 ? f.path.slice(0, slash) : ""}</span>
|
||||
<span className={styles.flex} />
|
||||
{counts.get(f.path) ? <span className={styles.dim}>{counts.get(f.path)} comments</span> : null}
|
||||
<span className={styles.added}>+{f.additions}</span>
|
||||
<span className={styles.deleted}>−{f.deletions}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Commits({ pr }: { pr: PullRequestDetails }) {
|
||||
return (
|
||||
<div className={styles.scroll} role="list" aria-label="Commits">
|
||||
{pr.commits.map((c) => (
|
||||
<div
|
||||
key={c.oid}
|
||||
role="listitem"
|
||||
className={styles.commitRow}
|
||||
title={c.body || c.headline}
|
||||
onContextMenu={(e) => {
|
||||
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}`) },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<span className={styles.ellipsis}>{c.headline}</span>
|
||||
<span className={styles.flex} />
|
||||
<span className={styles.dim}>{c.author}</span>
|
||||
<span className={styles.dim}>{relativeTime(c.date)}</span>
|
||||
<code className={styles.oid}>{c.oid.slice(0, 8)}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.scroll}>
|
||||
{pr.timeline.map((t, i) => (
|
||||
<article key={i} className={styles.comment}>
|
||||
<header className={styles.commentHead}>
|
||||
<strong>{t.author}</strong>
|
||||
{t.reviewState && <span className={styles[`review-${t.reviewState}`]}>{reviewStateLabel(t.reviewState)}</span>}
|
||||
<span className={styles.dim}>{relativeTime(t.createdAt)}</span>
|
||||
</header>
|
||||
{t.body.trim() && <Markdown text={t.body} />}
|
||||
</article>
|
||||
))}
|
||||
{threads.length > 0 && <h3 className={styles.sectionTitle}>Review comments</h3>}
|
||||
{threads.map((t) => (
|
||||
<article key={t.root.id} className={styles.comment}>
|
||||
<header className={styles.commentHead}>
|
||||
<button type="button" className={styles.link} onClick={() => void openCommentLocation(pr, t.root.path)}>
|
||||
{t.root.path}:{t.root.line ?? t.root.originalLine ?? "?"}
|
||||
</button>
|
||||
{t.root.line == null && <span className={styles.dim}>outdated</span>}
|
||||
</header>
|
||||
{[t.root, ...t.replies].map((c) => (
|
||||
<div key={c.id} className={styles.threadItem}>
|
||||
<div className={styles.commentHead}>
|
||||
<strong>{c.author}</strong>
|
||||
<span className={styles.dim}>{relativeTime(c.createdAt)}</span>
|
||||
</div>
|
||||
<Markdown text={c.body} />
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
<div className={styles.newComment}>
|
||||
<textarea
|
||||
aria-label="New comment"
|
||||
placeholder="Add a comment (Markdown)"
|
||||
className={styles.textarea}
|
||||
rows={4}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<div className={styles.filterRow}>
|
||||
<span className={styles.flex} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
disabled={!draft.trim() || posting}
|
||||
onClick={async () => {
|
||||
setPosting(true);
|
||||
if (await addComment(pr.summary.number, draft)) setDraft("");
|
||||
setPosting(false);
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <EmptyState title="Could not load the pull request">{error}</EmptyState>;
|
||||
return loading ? (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size={14} /> Loading…
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Select a pull request" />
|
||||
);
|
||||
}
|
||||
const s = pr.summary;
|
||||
const reviewMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<div className={styles.details}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<h2 className={styles.title}>{s.title}</h2>
|
||||
{loading && <Spinner size={12} />}
|
||||
</div>
|
||||
<div className={styles.metaRow}>
|
||||
<StateBadge pr={s} />
|
||||
<span>
|
||||
#{s.number} by <strong>{s.author}</strong> · {s.baseRef} ← {s.headRef}
|
||||
</span>
|
||||
{reviewDecisionLabel(s.reviewDecision) && <span className={styles.dim}>· {reviewDecisionLabel(s.reviewDecision)}</span>}
|
||||
{pr.mergeable === "CONFLICTING" && <span className={styles.conflict}>· Conflicts</span>}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button size="small" icon="branch" onClick={() => void checkoutPullRequest(s.number)}>
|
||||
Checkout
|
||||
</Button>
|
||||
<Button size="small" icon="edit" onClick={reviewMenu}>
|
||||
Review ▾
|
||||
</Button>
|
||||
<Button size="small" icon="external-link" onClick={() => void openUrl(s.url)}>
|
||||
Open in Browser
|
||||
</Button>
|
||||
<span className={styles.flex} />
|
||||
<IconButton icon="refresh" size="small" title="Reload" onClick={() => void loadDetails(s.number)} />
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
activeId={tab}
|
||||
onSelect={(id) => useGithub.setState({ tab: id as DetailTab })}
|
||||
aria-label="Pull request"
|
||||
items={[
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "files", label: `Files ${pr.files.length}` },
|
||||
{ id: "commits", label: `Commits ${pr.commits.length}` },
|
||||
{ id: "conversation", label: `Conversation ${pr.timeline.length + comments.length}` },
|
||||
]}
|
||||
/>
|
||||
{tab === "overview" && <Overview pr={pr} />}
|
||||
{tab === "files" && <Files pr={pr} />}
|
||||
{tab === "commits" && <Commits pr={pr} />}
|
||||
{tab === "conversation" && <Conversation pr={pr} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BusyBanner() {
|
||||
const busy = useGithub((s) => s.busy);
|
||||
if (!busy) return null;
|
||||
return (
|
||||
<div className={styles.banner} role="status">
|
||||
<Spinner size={12} />
|
||||
<span>{busy.title}</span>
|
||||
<span className={styles.bannerText}>{busy.line}</span>
|
||||
<IconButton icon="close" size="small" title="Cancel" onClick={cancelBusy} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement | null>(null);
|
||||
const narrow = useNarrow(root);
|
||||
useEffect(() => {
|
||||
if (!useGithub.getState().availability) void refreshAll();
|
||||
}, []);
|
||||
if (!availability)
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
{checking && <Spinner size={14} />} Connecting to GitHub…
|
||||
</div>
|
||||
);
|
||||
if (availability.kind !== "ready")
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<Unavailable availability={availability} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.view} ref={setRoot}>
|
||||
<BusyBanner />
|
||||
<SplitGroup key={narrow ? "v" : "h"} orientation={narrow ? "vertical" : "horizontal"} className={styles.split}>
|
||||
<SplitPanel defaultSize={narrow ? "40%" : "38%"} minSize={narrow ? "120px" : "220px"}>
|
||||
<PullRequestList />
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel minSize={narrow ? "160px" : "280px"}>
|
||||
<Details />
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Header content: the repository the list is for. */
|
||||
export function PullRequestsHeader() {
|
||||
const availability = useGithub((s) => s.availability);
|
||||
if (availability?.kind !== "ready") return null;
|
||||
return <span className={styles.repo}>{availability.repo.nameWithOwner}</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/** Create Pull Request, Submit Review and Checkout-by-number dialogs, rendered by one overlay. */
|
||||
import { Fragment, useEffect, useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { ReviewEvent } from "@/platform/ipc";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, Input, Select } from "@/ui";
|
||||
import styles from "./Github.module.css";
|
||||
|
||||
interface Entry {
|
||||
id: number;
|
||||
node: ReactNode;
|
||||
}
|
||||
|
||||
const useDialogs = create<{ stack: Entry[] }>(() => ({ stack: [] }));
|
||||
let seq = 0;
|
||||
|
||||
function open<T>(render: (done: (v: T | null) => void) => ReactNode): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
const id = ++seq;
|
||||
const done = (v: T | null) => {
|
||||
useDialogs.setState((s) => ({ stack: s.stack.filter((e) => e.id !== id) }));
|
||||
resolve(v);
|
||||
};
|
||||
useDialogs.setState((s) => ({ stack: [...s.stack, { id, node: render(done) }] }));
|
||||
});
|
||||
}
|
||||
|
||||
export function GithubDialogHost() {
|
||||
const stack = useDialogs((s) => s.stack);
|
||||
return (
|
||||
<>
|
||||
{stack.map((e) => (
|
||||
<Fragment key={e.id}>{e.node}</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CreatePrInput {
|
||||
title: string;
|
||||
body: string;
|
||||
base: string;
|
||||
draft: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
/** Asks for the new pull request's title, description, base branch and options. */
|
||||
export function createPullRequestDialog(opts: { head: string; defaultBase: string | null; title: string; needsPush: boolean }) {
|
||||
return open<CreatePrInput>((done) => <CreateDialog {...opts} onDone={done} />);
|
||||
}
|
||||
|
||||
function CreateDialog(props: { head: string; defaultBase: string | null; title: string; needsPush: boolean; onDone: (v: CreatePrInput | null) => void }) {
|
||||
const [title, setTitle] = useState(props.title);
|
||||
const [body, setBody] = useState("");
|
||||
const [base, setBase] = useState(props.defaultBase ?? "");
|
||||
const [bases, setBases] = useState<string[]>(props.defaultBase ? [props.defaultBase] : []);
|
||||
const [draft, setDraft] = useState(false);
|
||||
const [push, setPush] = useState(props.needsPush);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
// Base candidates: branches of the remotes (without `origin/`), the default first.
|
||||
call(commands.vcsBranches())
|
||||
.then((branches) => {
|
||||
const names = new Set<string>();
|
||||
if (props.defaultBase) names.add(props.defaultBase);
|
||||
for (const b of branches) {
|
||||
if (!b.remote || b.name.endsWith("/HEAD")) continue;
|
||||
const short = b.name.slice(b.name.indexOf("/") + 1);
|
||||
if (short !== props.head) names.add(short);
|
||||
}
|
||||
if (alive) {
|
||||
setBases([...names]);
|
||||
setBase((cur) => cur || [...names][0] || "");
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [props.defaultBase, props.head]);
|
||||
|
||||
const ok = !!title.trim() && !!base;
|
||||
const submit = () => ok && props.onDone({ title: title.trim(), body, base, draft, push });
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Create Pull Request"
|
||||
onClose={() => props.onDone(null)}
|
||||
width={560}
|
||||
buttons={[
|
||||
{ label: draft ? "Create Draft" : "Create", role: "default", disabled: !ok, onClick: submit },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formRow}>
|
||||
<label className={styles.fieldLabel}>Base:</label>
|
||||
<Select value={base || null} options={bases.map((b) => ({ value: b, label: b }))} onChange={setBase} aria-label="Base branch" />
|
||||
<span className={styles.fieldLabel}>← {props.head}</span>
|
||||
</div>
|
||||
<Input data-autofocus aria-label="Title" placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<textarea
|
||||
aria-label="Description"
|
||||
placeholder="Description (Markdown)"
|
||||
className={styles.textarea}
|
||||
rows={10}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<div className={styles.formRow}>
|
||||
<Checkbox checked={draft} onChange={setDraft} label="Draft" />
|
||||
<Checkbox checked={push} onChange={setPush} label={`Push '${props.head}' first`} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const REVIEW_LABEL: Record<ReviewEvent, string> = { approve: "Approve", requestChanges: "Request Changes", comment: "Comment" };
|
||||
|
||||
/** Asks for a review comment; resolves with the chosen event and body. */
|
||||
export function reviewDialog(number: number, initial: ReviewEvent) {
|
||||
return open<{ event: ReviewEvent; body: string }>((done) => <ReviewDialog number={number} initial={initial} onDone={done} />);
|
||||
}
|
||||
|
||||
function ReviewDialog(props: { number: number; initial: ReviewEvent; onDone: (v: { event: ReviewEvent; body: string } | null) => void }) {
|
||||
const [body, setBody] = useState("");
|
||||
const [event, setEvent] = useState<ReviewEvent>(props.initial);
|
||||
const needsBody = event !== "approve";
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={`Review #${props.number}`}
|
||||
onClose={() => props.onDone(null)}
|
||||
width={520}
|
||||
buttons={[
|
||||
{ label: REVIEW_LABEL[event], role: "default", disabled: needsBody && !body.trim(), onClick: () => props.onDone({ event, body }) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<Select<ReviewEvent>
|
||||
value={event}
|
||||
onChange={setEvent}
|
||||
aria-label="Review action"
|
||||
options={(Object.keys(REVIEW_LABEL) as ReviewEvent[]).map((v) => ({ value: v, label: REVIEW_LABEL[v] }))}
|
||||
/>
|
||||
<textarea
|
||||
data-autofocus
|
||||
aria-label="Review comment"
|
||||
placeholder={needsBody ? "Leave a comment (required)" : "Leave a comment (optional)"}
|
||||
className={styles.textarea}
|
||||
rows={8}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks for a pull request number (Git › GitHub › Checkout Pull Request…). */
|
||||
export function numberDialog(title: string) {
|
||||
return open<number>((done) => <NumberDialog title={title} onDone={done} />);
|
||||
}
|
||||
|
||||
function NumberDialog(props: { title: string; onDone: (v: number | null) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
const n = Number.parseInt(value.replace(/^#/, ""), 10);
|
||||
const ok = Number.isInteger(n) && n > 0;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={props.title}
|
||||
onClose={() => props.onDone(null)}
|
||||
width={360}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", disabled: !ok, onClick: () => props.onDone(n) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (ok) props.onDone(n);
|
||||
}}
|
||||
>
|
||||
<label className={styles.fieldLabel}>Pull request number:</label>
|
||||
<Input data-autofocus mono aria-label="Pull request number" placeholder="#123" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrCheck, PrReviewComment, PullRequestDetails } from "@/platform/ipc";
|
||||
import { checksSummary, checksText, diffNotes, fileDiffData, relativeTime, reviewThreads, stateLabel } from "./model";
|
||||
|
||||
const comment = (id: number, path: string, line: number | null, inReplyTo: number | null = null, side = "RIGHT"): PrReviewComment => ({
|
||||
id,
|
||||
inReplyTo,
|
||||
path,
|
||||
line,
|
||||
originalLine: line ?? 3,
|
||||
side,
|
||||
body: `c${id}`,
|
||||
author: "ann",
|
||||
createdAt: "2026-10-21T10:00:00Z",
|
||||
url: "",
|
||||
diffHunk: "",
|
||||
});
|
||||
|
||||
const summary = {
|
||||
number: 7,
|
||||
title: "Fix",
|
||||
author: "ann",
|
||||
state: "open" as const,
|
||||
isDraft: false,
|
||||
headRef: "fix",
|
||||
baseRef: "main",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
labels: [],
|
||||
reviewDecision: null,
|
||||
url: "https://github.com/o/r/pull/7",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
};
|
||||
|
||||
describe("pull request model", () => {
|
||||
it("labels states", () => {
|
||||
expect(stateLabel(summary)).toBe("Open");
|
||||
expect(stateLabel({ ...summary, isDraft: true })).toBe("Draft");
|
||||
expect(stateLabel({ ...summary, state: "merged" })).toBe("Merged");
|
||||
});
|
||||
|
||||
it("summarises checks", () => {
|
||||
const c = (status: PrCheck["status"]): PrCheck => ({ name: status, workflow: null, status, url: null });
|
||||
const s = checksSummary([c("failure"), c("success"), c("success"), c("pending"), c("skipped")]);
|
||||
expect(s).toEqual({ failed: 1, pending: 1, passed: 2, other: 1 });
|
||||
expect(checksText(s)).toBe("1 failing, 1 pending, 2 successful, 1 skipped");
|
||||
expect(checksText(checksSummary([]))).toBe("No checks");
|
||||
});
|
||||
|
||||
it("formats relative times", () => {
|
||||
const now = Date.parse("2026-10-21T12:00:00Z");
|
||||
expect(relativeTime("2026-10-21T11:59:30Z", now)).toBe("just now");
|
||||
expect(relativeTime("2026-10-21T10:00:00Z", now)).toBe("2 hours ago");
|
||||
expect(relativeTime("2026-10-20T12:00:00Z", now)).toBe("1 day ago");
|
||||
expect(relativeTime("nonsense", now)).toBe("");
|
||||
});
|
||||
|
||||
it("threads replies under their root, ordered by file and line", () => {
|
||||
const threads = reviewThreads([comment(1, "b.ts", 9), comment(2, "a.ts", 5), comment(3, "b.ts", 9, 1), comment(4, "b.ts", 9, 3)]);
|
||||
expect(threads.map((t) => [t.root.id, t.replies.map((r) => r.id)])).toEqual([
|
||||
[2, []],
|
||||
[1, [3, 4]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns current comments into diff notes", () => {
|
||||
const notes = diffNotes("a.ts", [comment(1, "a.ts", 5), comment(2, "a.ts", null), comment(3, "a.ts", 2, null, "LEFT"), comment(4, "b.ts", 1)]);
|
||||
expect(notes.map((n) => [n.side, n.line])).toEqual([
|
||||
["right", 4],
|
||||
["left", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds diff data for renamed and added files", () => {
|
||||
const pr = { summary, files: [] } as unknown as PullRequestDetails;
|
||||
const revs = {
|
||||
base: "b".repeat(40),
|
||||
head: "h".repeat(40),
|
||||
root: "/repo",
|
||||
files: [{ path: "src/new.ts", kind: "renamed" as const, oldPath: "src/old.ts" }],
|
||||
};
|
||||
const renamed = fileDiffData(pr, { path: "src/new.ts", additions: 1, deletions: 0, changeType: "RENAMED" }, revs, []);
|
||||
expect(renamed.path).toBe("/repo/src/new.ts");
|
||||
expect(renamed.left).toMatchObject({ type: "rev", rev: revs.base, path: "/repo/src/old.ts" });
|
||||
expect(renamed.right).toMatchObject({ type: "rev", rev: revs.head });
|
||||
expect(renamed.title).toBe("#7: new.ts");
|
||||
const added = fileDiffData(pr, { path: "x.md", additions: 1, deletions: 0, changeType: "ADDED" }, revs, []);
|
||||
expect(added.left).toMatchObject({ type: "text", text: "" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* GitHub pull requests through the gh CLI: the Pull Requests tool window (list,
|
||||
* details, files diffed in the diff viewer with review comments, commits,
|
||||
* conversation), checkout, create, review and comment.
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import { GithubDialogHost } from "./dialogs";
|
||||
import { checkoutAction, createPullRequest, openInBrowser, showPullRequests, WINDOW } from "./ops";
|
||||
import { PullRequestsHeader, PullRequestsView } from "./PullRequestsView";
|
||||
import { DEFAULT_QUERY, refreshAll, useGithub } from "./store";
|
||||
|
||||
const WHEN = "projectOpen";
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerToolWindow({
|
||||
id: WINDOW,
|
||||
title: "Pull Requests",
|
||||
icon: "toolwindow-pull-requests",
|
||||
anchor: "left",
|
||||
order: 2,
|
||||
stripe: "onUse",
|
||||
component: PullRequestsView,
|
||||
headerContent: PullRequestsHeader,
|
||||
}),
|
||||
registerOverlay({ id: "github.dialogs", component: GithubDialogHost }),
|
||||
registerActions([
|
||||
{
|
||||
id: "github.pullRequests",
|
||||
title: "View Pull Requests",
|
||||
category: "GitHub",
|
||||
icon: "toolwindow-pull-requests",
|
||||
keywords: ["github", "pr", "review"],
|
||||
when: WHEN,
|
||||
run: () => showPullRequests(),
|
||||
},
|
||||
{
|
||||
id: "github.createPullRequest",
|
||||
title: "Create Pull Request…",
|
||||
category: "GitHub",
|
||||
icon: "plus",
|
||||
keywords: ["github", "pr"],
|
||||
when: WHEN,
|
||||
run: () => createPullRequest(),
|
||||
},
|
||||
{
|
||||
id: "github.checkoutPullRequest",
|
||||
title: "Checkout Pull Request…",
|
||||
description: "Checks out the selected pull request, a number given as argument, or asks for one.",
|
||||
category: "GitHub",
|
||||
icon: "branch",
|
||||
keywords: ["github", "pr"],
|
||||
when: WHEN,
|
||||
run: ({ arg }) => checkoutAction(arg),
|
||||
},
|
||||
{
|
||||
id: "github.openInBrowser",
|
||||
title: "Open on GitHub",
|
||||
category: "GitHub",
|
||||
icon: "external-link",
|
||||
when: WHEN,
|
||||
run: () => openInBrowser(),
|
||||
},
|
||||
{ id: "github.refresh", title: "Refresh Pull Requests", category: "GitHub", icon: "refresh", when: WHEN, run: () => refreshAll() },
|
||||
]),
|
||||
// Another project: start over.
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root === prev.project?.root) return;
|
||||
useGithub.setState({ availability: null, list: [], selected: null, details: null, reviewComments: [], query: DEFAULT_QUERY });
|
||||
}),
|
||||
];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/** Pure helpers of the Pull Requests view (labels, check summaries, review threads, diff inputs). */
|
||||
import type { ChangedFile, PrCheck, PrDiffRevisions, PrFile, PrReviewComment, PullRequestDetails, PullRequestSummary } from "@/platform/ipc";
|
||||
|
||||
export function stateLabel(pr: PullRequestSummary): string {
|
||||
if (pr.state === "merged") return "Merged";
|
||||
if (pr.state === "closed") return "Closed";
|
||||
return pr.isDraft ? "Draft" : "Open";
|
||||
}
|
||||
|
||||
export function reviewDecisionLabel(decision: string | null): string | null {
|
||||
switch (decision) {
|
||||
case "APPROVED":
|
||||
return "Approved";
|
||||
case "CHANGES_REQUESTED":
|
||||
return "Changes requested";
|
||||
case "REVIEW_REQUIRED":
|
||||
return "Review required";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewStateLabel(state: string): string {
|
||||
switch (state) {
|
||||
case "APPROVED":
|
||||
return "approved";
|
||||
case "CHANGES_REQUESTED":
|
||||
return "requested changes";
|
||||
case "COMMENTED":
|
||||
return "reviewed";
|
||||
case "DISMISSED":
|
||||
return "review dismissed";
|
||||
case "REQUESTED":
|
||||
return "review requested";
|
||||
default:
|
||||
return state.toLowerCase().replace(/_/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChecksSummary {
|
||||
failed: number;
|
||||
pending: number;
|
||||
passed: number;
|
||||
/** Skipped / neutral / cancelled. */
|
||||
other: number;
|
||||
}
|
||||
|
||||
export function checksSummary(checks: readonly PrCheck[]): ChecksSummary {
|
||||
const s: ChecksSummary = { failed: 0, pending: 0, passed: 0, other: 0 };
|
||||
for (const c of checks) {
|
||||
if (c.status === "failure") s.failed++;
|
||||
else if (c.status === "pending") s.pending++;
|
||||
else if (c.status === "success") s.passed++;
|
||||
else s.other++;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function checksText(s: ChecksSummary): string {
|
||||
const parts: string[] = [];
|
||||
if (s.failed) parts.push(`${s.failed} failing`);
|
||||
if (s.pending) parts.push(`${s.pending} pending`);
|
||||
if (s.passed) parts.push(`${s.passed} successful`);
|
||||
if (s.other) parts.push(`${s.other} skipped`);
|
||||
return parts.length ? parts.join(", ") : "No checks";
|
||||
}
|
||||
|
||||
/** "3 minutes ago"-style text for an ISO date. */
|
||||
export function relativeTime(iso: string, now = Date.now()): string {
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return "";
|
||||
const s = Math.max(0, Math.round((now - t) / 1000));
|
||||
const units: Array<[number, string]> = [
|
||||
[60 * 60 * 24 * 365, "year"],
|
||||
[60 * 60 * 24 * 30, "month"],
|
||||
[60 * 60 * 24, "day"],
|
||||
[60 * 60, "hour"],
|
||||
[60, "minute"],
|
||||
];
|
||||
for (const [size, name] of units) {
|
||||
const n = Math.floor(s / size);
|
||||
if (n >= 1) return `${n} ${name}${n === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
export interface ReviewThread {
|
||||
root: PrReviewComment;
|
||||
replies: PrReviewComment[];
|
||||
}
|
||||
|
||||
/** Groups inline comments into threads (replies under their root), roots in file / line order. */
|
||||
export function reviewThreads(comments: readonly PrReviewComment[]): ReviewThread[] {
|
||||
const byId = new Map<number, ReviewThread>();
|
||||
const threads: ReviewThread[] = [];
|
||||
for (const c of comments) {
|
||||
const parent = c.inReplyTo != null ? byId.get(c.inReplyTo) : undefined;
|
||||
if (parent) {
|
||||
parent.replies.push(c);
|
||||
byId.set(c.id, parent);
|
||||
} else {
|
||||
const t = { root: c, replies: [] };
|
||||
threads.push(t);
|
||||
byId.set(c.id, t);
|
||||
}
|
||||
}
|
||||
return threads.sort(
|
||||
(a, b) => a.root.path.localeCompare(b.root.path) || (a.root.line ?? a.root.originalLine ?? 0) - (b.root.line ?? b.root.originalLine ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
/** Annotation shape of the diff viewer (`vcs.openDiff` → DiffData.annotations). */
|
||||
export interface DiffNote {
|
||||
side: "left" | "right";
|
||||
line: number;
|
||||
author: string;
|
||||
body: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/** Current (not outdated) review comments of `path`, as diff notes. */
|
||||
export function diffNotes(path: string, comments: readonly PrReviewComment[]): DiffNote[] {
|
||||
return comments
|
||||
.filter((c) => c.path === path && c.line != null)
|
||||
.map((c) => ({ side: c.side === "LEFT" ? "left" : "right", line: c.line! - 1, author: c.author, body: c.body, date: c.createdAt }));
|
||||
}
|
||||
|
||||
type Side =
|
||||
| { type: "rev"; rev: string; path?: string; label?: string }
|
||||
| { type: "text"; text: string; label: string };
|
||||
|
||||
/** `DiffData` for one changed file of a pull request (merge base ↔ head), with its review comments. */
|
||||
export function fileDiffData(pr: PullRequestDetails, file: PrFile, revs: PrDiffRevisions, comments: readonly PrReviewComment[]) {
|
||||
const root = revs.root;
|
||||
const changed: ChangedFile | undefined = revs.files.find((f) => f.path === file.path);
|
||||
const added = file.changeType === "ADDED" || changed?.kind === "added";
|
||||
const deleted = file.changeType === "DELETED" || changed?.kind === "deleted";
|
||||
const n = pr.summary.number;
|
||||
const left: Side = added
|
||||
? { type: "text", text: "", label: "(does not exist)" }
|
||||
: {
|
||||
type: "rev",
|
||||
rev: revs.base,
|
||||
path: changed?.oldPath ? `${root}/${changed.oldPath}` : undefined,
|
||||
label: `${pr.summary.baseRef} (${revs.base.slice(0, 8)})`,
|
||||
};
|
||||
const right: Side = deleted ? { type: "text", text: "", label: "(deleted)" } : { type: "rev", rev: revs.head, label: `#${n} ${pr.summary.headRef}` };
|
||||
const name = file.path.slice(file.path.lastIndexOf("/") + 1);
|
||||
return { path: `${root}/${file.path}`, left, right, title: `#${n}: ${name}`, annotations: diffNotes(file.path, comments) };
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Pull request operations shared by the view and the `github.*` actions. */
|
||||
import type { PrFile, PullRequestDetails, ReviewEvent } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { createPullRequestDialog, numberDialog, reviewDialog } from "./dialogs";
|
||||
import { fileDiffData } from "./model";
|
||||
import { diffRevisions, errorMessage, loadDetails, loadList, readyRepo, refreshAll, useGithub, withBusy } from "./store";
|
||||
|
||||
export const WINDOW = "pullRequests";
|
||||
|
||||
export async function openUrl(url: string) {
|
||||
try {
|
||||
const { openUrl: open } = await import("@tauri-apps/plugin-opener");
|
||||
await open(url);
|
||||
} catch {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the tool window, (re)checking gh first when needed. */
|
||||
export async function showPullRequests() {
|
||||
toolWindowService().show(WINDOW, true);
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
}
|
||||
|
||||
function notReady(): boolean {
|
||||
const a = useGithub.getState().availability;
|
||||
if (a?.kind === "ready") return false;
|
||||
void showPullRequests();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkoutPullRequest(number: number) {
|
||||
const done = await withBusy(`Checking out #${number}…`, (r, out) => call(commands.ghPrCheckout(r, number, out)));
|
||||
if (done !== null)
|
||||
notifications().notify({ group: "vcs", level: "info", title: `Checked out pull request #${number}`, key: "github-checkout" });
|
||||
}
|
||||
|
||||
/** Git › GitHub › Checkout Pull Request…: the selected one, else asks for a number. */
|
||||
export async function checkoutAction(arg: unknown) {
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
if (notReady()) return;
|
||||
const n = typeof arg === "number" ? arg : (useGithub.getState().selected ?? (await numberDialog("Checkout Pull Request")));
|
||||
if (n) await checkoutPullRequest(n);
|
||||
}
|
||||
|
||||
export async function openFileDiff(pr: PullRequestDetails, file: PrFile) {
|
||||
const revs = await diffRevisions(pr);
|
||||
if (!revs) return;
|
||||
await executeAction("vcs.openDiff", fileDiffData(pr, file, revs, useGithub.getState().reviewComments));
|
||||
}
|
||||
|
||||
/** Opens a review comment's file at its line in the PR diff. */
|
||||
export async function openCommentLocation(pr: PullRequestDetails, path: string) {
|
||||
const file = pr.files.find((f) => f.path === path) ?? { path, additions: 0, deletions: 0, changeType: "MODIFIED" };
|
||||
await openFileDiff(pr, file);
|
||||
}
|
||||
|
||||
export async function submitReview(number: number, initial: ReviewEvent) {
|
||||
const r = await reviewDialog(number, initial);
|
||||
if (!r) return;
|
||||
try {
|
||||
await call(commands.ghPrReview(number, r.event, r.body));
|
||||
notifications().notify({ group: "vcs", level: "info", title: `Review submitted on #${number}`, key: "github-review" });
|
||||
await loadDetails(number);
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "vcs", level: "error", title: "Could not submit the review", body: errorMessage(e), key: "github-review" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function addComment(number: number, body: string): Promise<boolean> {
|
||||
try {
|
||||
await call(commands.ghPrComment(number, body));
|
||||
await loadDetails(number);
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "vcs", level: "error", title: "Could not add the comment", body: errorMessage(e), key: "github-comment" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Git › GitHub › Create Pull Request…: from the current branch. */
|
||||
export async function createPullRequest() {
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
if (notReady()) return;
|
||||
const repo = readyRepo();
|
||||
let head: string | null = null;
|
||||
let needsPush = true;
|
||||
let title = "";
|
||||
try {
|
||||
const info = await call(commands.vcsBranchInfo());
|
||||
head = info.head;
|
||||
needsPush = !info.upstream || info.ahead > 0;
|
||||
if (head) title = head.replace(/^[^/]+\//, "").replace(/[-_]+/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
||||
} catch {
|
||||
// Not a git repository: gh will explain.
|
||||
}
|
||||
if (!head) {
|
||||
notifications().notify({ group: "vcs", level: "warning", title: "Check out a branch to create a pull request from", key: "github-create" });
|
||||
return;
|
||||
}
|
||||
if (head === repo?.defaultBranch) {
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "warning",
|
||||
title: `'${head}' is the default branch`,
|
||||
body: "Create a branch for your changes first.",
|
||||
key: "github-create",
|
||||
actions: [{ label: "New Branch…", run: () => void executeAction("vcs.newBranch") }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const input = await createPullRequestDialog({ head, defaultBase: repo?.defaultBranch ?? null, title, needsPush });
|
||||
if (!input) return;
|
||||
const url = await withBusy(`Creating pull request…`, (r, out) => call(commands.ghPrCreate(r, input, out)));
|
||||
if (url === null) return;
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "info",
|
||||
title: "Pull request created",
|
||||
body: url || undefined,
|
||||
key: "github-create",
|
||||
actions: url ? [{ label: "Open in Browser", run: () => void openUrl(url) }] : undefined,
|
||||
});
|
||||
toolWindowService().show(WINDOW);
|
||||
await loadList();
|
||||
const m = /\/pull\/(\d+)/.exec(url);
|
||||
if (m) await loadDetails(Number(m[1]));
|
||||
}
|
||||
|
||||
/** Opens the selected pull request, else the repository, on GitHub. */
|
||||
export async function openInBrowser() {
|
||||
if (useGithub.getState().availability?.kind !== "ready") await refreshAll();
|
||||
if (notReady()) return;
|
||||
const { details } = useGithub.getState();
|
||||
const url = details?.summary.url ?? readyRepo()?.url;
|
||||
if (url) await openUrl(url);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Pull request state of the Pull Requests tool window: gh availability, list query, selection, details. */
|
||||
import { create } from "zustand";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
GhAvailability,
|
||||
PrDiffRevisions,
|
||||
PrListQuery,
|
||||
PrReviewComment,
|
||||
PullRequestDetails,
|
||||
PullRequestSummary,
|
||||
RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export type DetailTab = "overview" | "files" | "commits" | "conversation";
|
||||
|
||||
interface GithubState {
|
||||
availability: GhAvailability | null;
|
||||
checking: boolean;
|
||||
query: PrListQuery;
|
||||
list: PullRequestSummary[];
|
||||
listLoading: boolean;
|
||||
listError: string | null;
|
||||
selected: number | null;
|
||||
details: PullRequestDetails | null;
|
||||
detailsLoading: boolean;
|
||||
detailsError: string | null;
|
||||
reviewComments: PrReviewComment[];
|
||||
tab: DetailTab;
|
||||
/** Running gh operation (checkout, create, fetch for diff), with its last output line. */
|
||||
busy: { title: string; line: string; request: RequestId } | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_QUERY: PrListQuery = {
|
||||
state: "open",
|
||||
authorMe: false,
|
||||
reviewRequested: false,
|
||||
assigneeMe: false,
|
||||
search: "",
|
||||
limit: 100,
|
||||
};
|
||||
|
||||
export const useGithub = create<GithubState>(() => ({
|
||||
availability: null,
|
||||
checking: false,
|
||||
query: DEFAULT_QUERY,
|
||||
list: [],
|
||||
listLoading: false,
|
||||
listError: null,
|
||||
selected: null,
|
||||
details: null,
|
||||
detailsLoading: false,
|
||||
detailsError: null,
|
||||
reviewComments: [],
|
||||
tab: "overview",
|
||||
busy: null,
|
||||
}));
|
||||
|
||||
export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
export function readyRepo() {
|
||||
const a = useGithub.getState().availability;
|
||||
return a?.kind === "ready" ? a.repo : null;
|
||||
}
|
||||
|
||||
export async function checkAvailability(): Promise<GhAvailability | null> {
|
||||
if (!isTauri()) return null;
|
||||
useGithub.setState({ checking: true });
|
||||
try {
|
||||
const availability = await call(commands.ghAvailability());
|
||||
useGithub.setState({ availability });
|
||||
return availability;
|
||||
} catch (e) {
|
||||
const availability: GhAvailability = { kind: "error", message: errorMessage(e) };
|
||||
useGithub.setState({ availability });
|
||||
return availability;
|
||||
} finally {
|
||||
useGithub.setState({ checking: false });
|
||||
}
|
||||
}
|
||||
|
||||
let listSeq = 0;
|
||||
export async function loadList(): Promise<void> {
|
||||
if (useGithub.getState().availability?.kind !== "ready") return;
|
||||
const seq = ++listSeq;
|
||||
useGithub.setState({ listLoading: true, listError: null });
|
||||
try {
|
||||
const list = await call(commands.ghPrList(useGithub.getState().query));
|
||||
if (seq === listSeq) useGithub.setState({ list });
|
||||
} catch (e) {
|
||||
if (seq === listSeq) useGithub.setState({ listError: errorMessage(e), list: [] });
|
||||
} finally {
|
||||
if (seq === listSeq) useGithub.setState({ listLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-checks gh and reloads the list (and the open details). */
|
||||
export async function refreshAll(): Promise<void> {
|
||||
const a = await checkAvailability();
|
||||
if (a?.kind !== "ready") return;
|
||||
await loadList();
|
||||
const n = useGithub.getState().selected;
|
||||
if (n !== null) await loadDetails(n);
|
||||
}
|
||||
|
||||
export function setQuery(patch: Partial<PrListQuery>) {
|
||||
useGithub.setState((s) => ({ query: { ...s.query, ...patch } }));
|
||||
void loadList();
|
||||
}
|
||||
|
||||
let detailSeq = 0;
|
||||
export async function loadDetails(number: number): Promise<void> {
|
||||
const seq = ++detailSeq;
|
||||
const same = useGithub.getState().details?.summary.number === number;
|
||||
useGithub.setState({ selected: number, detailsLoading: true, detailsError: null, ...(same ? {} : { details: null, reviewComments: [] }) });
|
||||
try {
|
||||
const [details, comments] = await Promise.all([
|
||||
call(commands.ghPrView(number)),
|
||||
call(commands.ghPrReviewComments(number)).catch(() => [] as PrReviewComment[]),
|
||||
]);
|
||||
if (seq === detailSeq) useGithub.setState({ details, reviewComments: comments });
|
||||
} catch (e) {
|
||||
if (seq === detailSeq) useGithub.setState({ detailsError: errorMessage(e) });
|
||||
} finally {
|
||||
if (seq === detailSeq) useGithub.setState({ detailsLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function select(number: number | null) {
|
||||
if (number === null) {
|
||||
useGithub.setState({ selected: null, details: null, reviewComments: [] });
|
||||
return;
|
||||
}
|
||||
if (useGithub.getState().selected !== number) void loadDetails(number);
|
||||
}
|
||||
|
||||
/** Runs a cancellable, streaming gh operation shown in the view's banner. */
|
||||
export async function withBusy<T>(title: string, run: (request: RequestId, out: Channel<string>) => Promise<T>): Promise<T | null> {
|
||||
if (useGithub.getState().busy) {
|
||||
notifications().notify({ group: "vcs", level: "info", title: "Another GitHub operation is still running", key: "github-busy" });
|
||||
return null;
|
||||
}
|
||||
const request = nextRequestId();
|
||||
const out = new Channel<string>();
|
||||
out.onmessage = (line) => useGithub.setState((s) => (s.busy ? { busy: { ...s.busy, line } } : s));
|
||||
useGithub.setState({ busy: { title, line: "", request } });
|
||||
try {
|
||||
return await run(request, out);
|
||||
} catch (e) {
|
||||
if (!isCancelled(e))
|
||||
notifications().notify({ group: "vcs", level: "error", title: `${title.replace(/…$/, "")} failed`, body: errorMessage(e), key: "github-op" });
|
||||
return null;
|
||||
} finally {
|
||||
useGithub.setState({ busy: null });
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelBusy() {
|
||||
const b = useGithub.getState().busy;
|
||||
if (b) void commands.cancelRequest(b.request);
|
||||
}
|
||||
|
||||
const diffCache = new Map<string, PrDiffRevisions>();
|
||||
|
||||
/** Fetches what the local repository lacks to diff `pr`, once per head commit. */
|
||||
export async function diffRevisions(pr: PullRequestDetails): Promise<PrDiffRevisions | null> {
|
||||
const key = `${pr.summary.number}:${pr.headOid}:${pr.baseOid}`;
|
||||
const hit = diffCache.get(key);
|
||||
if (hit) return hit;
|
||||
const repo = readyRepo();
|
||||
const revs = await withBusy(`Fetching #${pr.summary.number}…`, (r, out) =>
|
||||
call(commands.ghPrPrepareDiff(r, pr.summary.number, pr.headOid, pr.baseOid, pr.summary.baseRef, repo?.nameWithOwner ?? "", out)),
|
||||
);
|
||||
if (revs) diffCache.set(key, revs);
|
||||
return revs;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача