Initial open-source release

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-22 18:55:10 +08:00
коміт 9a2025b76e
2034 files changed
+243381

No files matched your search

+367
Переглянути файл
@@ -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);
}
+532
Переглянути файл
@@ -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>;
}
+195
Переглянути файл
@@ -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>
);
}
+91
Переглянути файл
@@ -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: "" });
});
});
+75
Переглянути файл
@@ -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());
}
+150
Переглянути файл
@@ -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) };
}
+139
Переглянути файл
@@ -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);
}
+177
Переглянути файл
@@ -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;
}