feat(github): pull request tool window, timeline and review flow (#477)

Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication.

- List with search, quick filters and paging; a tab per pull request and a New Pull Request tab.
- Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen.
- Timeline editor: comments, review threads, reactions and suggested changes.
- Review in the diff viewer and, for the checked-out branch, in the editor (review mode).
- New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands.
- CSP: img-src allows https://avatars.githubusercontent.com for avatars.

Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane.

Co-authored-by: imacross <[email protected]>
Цей коміт міститься в:
imacross authored and GitHub committed 2026-10-31 07:50:43 +08:00
1 джерело 696377acc2
коміт 059fd8a69b
192 files changed
+23668 -2076

No files matched your search

+258
Переглянути файл
@@ -0,0 +1,258 @@
/**
* The commits and changes of the new pull request (`GHPRCreateComponentFactory.changesPanel`):
* "Changes" / "Changes from" with the commit chooser ("{n} commits", "All commits (n)"),
* the selected commit's details (Show / Hide details) and the changes tree, whose
* Show Diff opens the files in the diff viewer as one chain.
*/
import { useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { buildFileTree, dirKeys, type FileTreeNode } from "@/features/vcs/fileTree";
import { openDiff, withChain } from "@/features/vcs/diffInput";
import { KIND_COLOR, baseName, fileCount, type EffectiveKind } from "@/features/vcs/model";
import type { Commit } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { Icon, IconButton, Spinner, T, Tree, fileIconName, showContextMenu, type MenuItem } from "@/ui";
import { ChooserPopup } from "../ui/ChooserPopup";
import { prettyDateTime } from "../ui/prettyDate";
import { commitDescription } from "./model";
import { selectCommit, useCreatePr, type CreateChange } from "./store";
import styles from "./Create.module.css";
function Centered({ children, error }: { children: ReactNode; error?: boolean }) {
return <div className={error ? `${styles.centered} ${styles.errorText}` : styles.centered}>{children}</div>;
}
export function ChangesPanel() {
const commits = useCreatePr((s) => s.commits);
if (!commits)
return (
<Centered>
<T>Select pull request base and head to view changes</T>
</Centered>
);
if (commits.state === "loading")
return (
<Centered>
<Spinner size={14} />
</Centered>
);
if (commits.state === "error")
return (
<Centered error>
<div>
<T>Failed to load commits</T>
</div>
<div className={styles.errorDetail}>{commits.error}</div>
</Centered>
);
if (!commits.value.length)
return (
<Centered>
<span className={styles.hint}>
<T>Pull request does not contain any commits</T>
</span>
</Centered>
);
return <CommitsAndChanges commits={commits.value} />;
}
function CommitsAndChanges({ commits }: { commits: Commit[] }) {
const selectedId = useCreatePr((s) => s.selectedCommit);
const index = commits.findIndex((c) => c.id === selectedId);
const selected = index >= 0 ? commits[index]! : null;
return (
<div className={styles.changes}>
<div className={styles.commits}>
<div className={styles.commitsHeader}>
<span className={styles.commitsTitle}>{commits.length === 1 ? tx("Changes") : tx("Changes from")}</span>
<CommitChooser commits={commits} selected={selected} />
{commits.length > 1 && selected && (
<>
<IconButton
icon="arrow-down"
size="small"
title="Next Commit"
disabled={index === commits.length - 1}
onClick={() => selectCommit(commits[index + 1]!.id)}
/>
<IconButton
icon="arrow-up"
size="small"
title="Previous Commit"
disabled={index === 0}
onClick={() => selectCommit(commits[index - 1]!.id)}
/>
</>
)}
</div>
{selected && <CommitDetails key={selected.id} commit={selected} />}
</div>
<ChangesTree />
</div>
);
}
function CommitChooser({ commits, selected }: { commits: Commit[]; selected: Commit | null }) {
const ref = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const items = useMemo<Array<Commit | null>>(() => [null, ...commits], [commits]);
return (
<>
<button
ref={ref}
type="button"
className={styles.link}
disabled={commits.length <= 1}
onClick={() => setOpen(true)}
>
<span className={selected ? styles.hash : undefined}>
{selected ? selected.shortId : commits.length === 1 ? tx("1 commit") : tx`${commits.length} commits`}
</span>
{commits.length > 1 && <Icon name="chevron-down" size={12} />}
</button>
{open && (
<ChooserPopup<Commit | null>
mode="single"
anchor={ref}
items={items}
getKey={(c) => c?.id ?? "all"}
present={(c) =>
c
? {
text: c.summary,
detail: `${c.author}, ${c.timeMs ? prettyDateTime(c.timeMs) : ""}`,
icon: selected?.id === c.id ? "check" : "commit",
}
: { text: tx`All commits (${commits.length})`, icon: selected ? "commit" : "check" }
}
placeholder="Search commits"
aria-label="Commits"
width={360}
onChoose={(c) => selectCommit(c?.id ?? null)}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
/** `CodeReviewDetailsCommitInfoComponentFactory`: the subject, and with the details the body, author and date. */
function CommitDetails({ commit }: { commit: Commit }) {
const [details, setDetails] = useState(false);
const description = commitDescription(commit.message);
return (
<div className={styles.commitInfo}>
<div className={styles.commitTop}>
<span className={details ? styles.commitSubjectFull : styles.commitSubject}>{commit.summary}</span>
<button type="button" className={styles.textLink} onClick={() => setDetails((d) => !d)}>
{details ? tx("Hide details") : tx("Show details")}
</button>
</div>
{details && description && <div className={styles.commitBody}>{description}</div>}
{details && (
<div className={styles.commitMeta}>
{commit.author}, {commit.timeMs ? prettyDateTime(commit.timeMs) : ""}
</div>
)}
</div>
);
}
const kindOf = (c: CreateChange): EffectiveKind => (c.kind === "untracked" ? "added" : c.kind);
/** Show Diff: `first` with every file of the tree in the chain (F7 moves along). */
function showDiff(all: readonly CreateChange[], first: CreateChange) {
void openDiff(
withChain(
all.map((c) => c.diff),
Math.max(0, all.indexOf(first)),
),
);
}
function ChangesTree() {
const changes = useCreatePr((s) => s.changes);
const files = changes?.state === "ok" ? changes.value : null;
const roots = useMemo(() => (files ? buildFileTree(files, (f) => f.path) : []), [files]);
const expanded = useMemo(() => dirKeys(roots), [roots]);
const ordered = useMemo(() => {
const out: CreateChange[] = [];
const walk = (ns: readonly FileTreeNode<CreateChange>[]) =>
ns.forEach((n) => (n.file ? out.push(n.file) : walk(n.children)));
walk(roots);
return out;
}, [roots]);
const [selection, setSelection] = useState<ReadonlySet<string>>(new Set());
if (!changes || changes.state === "loading")
return (
<Centered>
<Spinner size={14} />
</Centered>
);
if (changes.state === "error")
return (
<Centered error>
<div>
<T>Failed to load changes</T>
</div>
<div className={styles.errorDetail}>{changes.error}</div>
</Centered>
);
const selectedFiles = (keys: ReadonlySet<string>) => ordered.filter((f) => keys.has(`f:${f.path}`));
const menu = (node: FileTreeNode<CreateChange> | null, keys: Set<string>, e: MouseEvent) => {
const picked = selectedFiles(keys);
const first = picked[0] ?? node?.file ?? null;
const items: MenuItem[] = [
{ label: "Show Diff", icon: "diff", disabled: !first, run: () => first && showDiff(ordered, first) },
];
showContextMenu(e, items);
};
return (
<Tree<FileTreeNode<CreateChange>>
key={changes.value.length ? "files" : "empty"}
className={styles.tree}
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => n.name}
getChildren={(n) => n.children}
isExpandable={(n) => !n.file}
defaultExpanded={expanded}
selected={selection}
onSelectionChange={setSelection}
multiSelect
rowHeight={22}
aria-label="Changes"
speedSearch
onActivate={(n) => n.file && showDiff(ordered, n.file)}
onContextMenu={menu}
empty={
<Centered>
<span className={styles.hint}>
<T>Commit does not contain any changes</T>
</span>
</Centered>
}
renderNode={(n) => <ChangeRow node={n} />}
/>
);
}
function ChangeRow({ node }: { node: FileTreeNode<CreateChange> }) {
if (!node.file)
return (
<span className={styles.fileRow}>
<Icon name="folder" size={16} />
<span className={styles.fileName}>{node.name}</span>
<span className={styles.fileDetail}>{fileCount(node.count)}</span>
</span>
);
const f = node.file;
return (
<span className={styles.fileRow} title={f.oldPath ? `${f.oldPath} → ${f.path}` : f.path}>
<Icon name={fileIconName(node.name)} size={16} />
<span className={styles.fileName} style={{ color: KIND_COLOR[kindOf(f)] }}>
{node.name}
</span>
{f.oldPath && <span className={styles.fileDetail}>{tx`renamed from ${baseName(f.oldPath)}`}</span>}
</span>
);
}
+386
Переглянути файл
@@ -0,0 +1,386 @@
/*
* The New Pull Request tab. Gaps are IntelliJ's: 12px sides, 8px between the parts,
* 10px around the status line and the button.
*/
.root {
flex: 1;
min-height: 0;
}
.top,
.bottom {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.centered {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 0;
padding: 12px;
text-align: center;
}
.hint {
color: var(--fg-muted);
}
.errorText {
color: var(--danger);
}
.errorDetail {
max-width: 100%;
color: var(--fg-secondary);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
/* Link-style buttons: the direction dropdowns, the commit chooser, Show details, View. */
.link,
.textLink {
display: inline-flex;
align-items: center;
gap: 2px;
min-width: 0;
padding: 0;
border: none;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.link:hover:not(:disabled) .linkText,
.textLink:hover {
color: var(--link-hover, var(--link));
text-decoration: underline;
}
.link:disabled {
color: var(--fg);
cursor: default;
}
.linkText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Direction */
.direction {
display: flex;
flex: none;
align-items: center;
gap: 6px;
min-width: 0;
padding: 8px 12px;
}
/* Base and head share the width; each shows its whole text when it fits. */
.direction > .link {
flex: 0 1 auto;
max-width: calc(50% - 12px);
}
.arrow {
flex: none;
color: var(--fg-muted);
}
.headPopup {
display: flex;
flex-direction: column;
width: 300px;
height: 340px;
overflow: hidden;
}
.headRepo {
display: flex;
flex: none;
align-items: center;
gap: 6px;
padding: 8px 8px 0;
}
.headRepo > :last-child {
flex: 1;
min-width: 0;
}
.headRepoLabel {
flex: none;
color: var(--fg-secondary);
}
.headSearch {
flex: none;
padding: 6px 8px 4px;
}
.headList {
flex: 1;
min-height: 0;
padding-bottom: 4px;
}
.headSection {
display: flex;
align-items: center;
height: 100%;
padding: 0 10px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.headRow {
display: flex;
align-items: center;
gap: 6px;
height: 100%;
padding: 0 8px;
overflow: hidden;
white-space: nowrap;
}
.headRow[data-active] {
background: var(--bg-list-selected);
border-radius: var(--radius-selection);
}
/* Commits and changes */
.changesBox {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
border-top: 1px solid var(--border);
}
.changes {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.commits {
display: flex;
flex: none;
flex-direction: column;
gap: 8px;
max-height: 50%;
padding: 2px 12px 8px;
overflow: auto;
}
.commitsHeader {
display: flex;
align-items: center;
gap: 4px;
min-height: 28px;
}
.commitsTitle {
font-weight: 600;
}
.hash {
font-family: var(--font-mono);
}
.commitInfo {
display: flex;
flex-direction: column;
gap: 8px;
}
.commitTop {
display: flex;
align-items: flex-start;
gap: 8px;
}
.commitTop > .textLink {
flex: none;
margin-left: auto;
}
.commitSubject {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commitSubjectFull {
min-width: 0;
overflow-wrap: anywhere;
}
.commitBody {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.commitMeta {
color: var(--fg-muted);
}
.tree {
flex: 1;
min-height: 0;
border-top: 1px solid var(--border);
}
.fileRow {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
white-space: nowrap;
}
.fileName {
overflow: hidden;
text-overflow: ellipsis;
}
.fileDetail {
margin-left: 4px;
color: var(--fg-muted);
}
/* Title and description */
.text {
display: flex;
flex: 1;
flex-direction: column;
min-height: 60px;
}
.titleInput {
flex: none;
margin: 0;
border: none !important;
border-bottom: 1px solid var(--border) !important;
border-radius: 0 !important;
background: var(--bg-editor, var(--bg-input)) !important;
padding: 8px 12px !important;
height: auto !important;
box-shadow: none !important;
}
.descriptionBox {
position: relative;
display: flex;
flex: 1;
min-height: 0;
}
.description {
flex: 1;
min-height: 0;
padding: 8px 12px;
border: none;
outline: none;
background: var(--bg-editor, var(--bg-input));
color: var(--fg);
font: inherit;
resize: none;
}
.templateSpinner {
position: absolute;
top: 8px;
right: 12px;
}
/* Reviewers, assignees, labels */
.metadata {
display: grid;
flex: none;
grid-template-columns: max-content minmax(0, 1fr);
align-items: start;
padding: 8px 12px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--bg-editor, var(--bg-input));
}
.metaLabel {
padding: 6px 5px 6px 0;
color: var(--fg-muted);
}
.metaList {
display: flex;
flex-wrap: wrap;
align-items: center;
min-width: 0;
min-height: 28px;
}
.metaItem {
display: inline-flex;
align-items: center;
padding: 4px 3px;
}
.userItem {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Status and the option button */
.footer {
display: flex;
flex: none;
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 10px;
}
.status {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 100%;
}
.statusRow {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.statusText {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.errorPopup {
width: 300px;
max-height: 400px;
padding: 10px 12px;
overflow: auto;
resize: both;
}
.errorPopupTitle {
margin-bottom: 6px;
color: var(--danger);
}
.errorPopupText {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.optionButton {
display: inline-flex;
align-items: stretch;
}
.optionButton > :first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.optionArrow {
display: inline-flex;
align-items: center;
padding: 0 4px;
border: none;
border-left: 1px solid color-mix(in srgb, var(--fg-button-primary) 35%, transparent);
border-radius: 0 var(--radius-control, 4px) var(--radius-control, 4px) 0;
background: var(--bg-button-primary);
color: var(--fg-button-primary);
cursor: pointer;
}
.optionArrow:disabled {
opacity: 0.5;
cursor: default;
}
/* New Remote Branch for Pull Request */
.dialogForm {
display: flex;
flex-direction: column;
gap: 6px;
}
.dialogLabel {
color: var(--fg);
}
.dialogComment {
color: var(--fg-muted);
font-size: var(--fs-small);
}
+263
Переглянути файл
@@ -0,0 +1,263 @@
/**
* The "New Pull Request at {repo}" tab (IntelliJ's `GHPRCreateComponentFactory`): a
* vertical splitter with the merge direction and the commits and changes above, and the
* title and description, reviewers / assignees / labels, the status line and the
* "Create Pull Request" option button (with "Create Draft Pull Request") below.
*
* Contract: `CreatePanel()` takes no props and renders inside the New Pull Request tab
* while the tool window is connected. Its state (`store.ts`) lives as long as the tab.
*/
import { useEffect, useRef, useState } from "react";
import { useVcs } from "@/features/vcs/store";
import { tx } from "@/platform/i18n";
import {
Button,
Icon,
IconButton,
Input,
Menu,
Popup,
SplitGroup,
SplitHandle,
SplitPanel,
Spinner,
T,
type MenuItem,
} from "@/ui";
import { openPullRequest } from "../toolwindow/open";
import { ChangesPanel } from "./ChangesPanel";
import { DirectionSelector } from "./DirectionSelector";
import { MetadataGrid } from "./MetadataGrid";
import {
canCreate,
createFromForm,
initCreate,
onRepositoryChanged,
refreshBranchesState,
resetCreate,
setDescription,
setTitle,
useCreatePr,
} from "./store";
import styles from "./Create.module.css";
export function CreatePanel() {
const ready = useCreatePr((s) => s.ready);
const initError = useCreatePr((s) => s.initError);
useEffect(() => {
void initCreate();
}, []);
// Commits follow the repository (a new commit, a push, a fetch).
useEffect(
() =>
useVcs.subscribe((s, prev) => {
if (s.version !== prev.version) void onRepositoryChanged();
}),
[],
);
if (initError)
return (
<div className={styles.centered}>
<div className={styles.errorText}>{initError}</div>
<Button
size="small"
onClick={() => {
resetCreate();
void initCreate();
}}
>
<T>Retry</T>
</Button>
</div>
);
if (!ready)
return (
<div className={styles.centered}>
<Spinner size={14} />
</div>
);
return (
<SplitGroup orientation="vertical" id="github-create-pull-request" className={styles.root}>
<SplitPanel id="top" defaultSize="50%" minSize="90px">
<div className={styles.top}>
<DirectionSelector />
<div className={styles.changesBox}>
<ChangesPanel />
</div>
</div>
</SplitPanel>
<SplitHandle />
<SplitPanel id="bottom" defaultSize="50%" minSize="160px">
<div className={styles.bottom}>
<TextPanel />
<MetadataGrid />
<div className={styles.footer}>
<StatusLine />
<CreateButton />
</div>
</div>
</SplitPanel>
</SplitGroup>
);
}
/** `CodeReviewTitleDescriptionComponentFactory`: the title and description editors. */
function TextPanel() {
const title = useCreatePr((s) => s.title);
const description = useCreatePr((s) => s.description);
const templateLoading = useCreatePr((s) => s.templateLoading);
return (
<div className={styles.text}>
<Input
className={styles.titleInput}
aria-label="Title"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div className={styles.descriptionBox}>
<textarea
className={styles.description}
aria-label={tx("Description")}
placeholder={tx("Description")}
value={description}
disabled={templateLoading}
onChange={(e) => setDescription(e.target.value)}
/>
{templateLoading && (
<span className={styles.templateSpinner}>
<Spinner size={12} />
</span>
)}
</div>
</div>
);
}
/** "Details": the error in a popup above the link. */
function ErrorDetails({ title, error }: { title: string; error: string }) {
const ref = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return (
<>
<button ref={ref} type="button" className={styles.textLink} onClick={() => setOpen(true)}>
<T>Details</T>
</button>
{open && (
<Popup
open
anchor={ref}
placement="top-start"
offset={4}
onClose={() => setOpen(false)}
role="dialog"
aria-label={title}
className={styles.errorPopup}
>
<div className={styles.errorPopupTitle}>{title}</div>
<div className={styles.errorPopupText}>{error}</div>
</Popup>
)}
</>
);
}
function ErrorLabel({ text }: { text: string }) {
return (
<span className={styles.statusText} title={text}>
<Icon name="error" size={16} />
<span className={styles.ellipsis}>{text}</span>
</span>
);
}
function LoadingLabel({ text }: { text: string }) {
return (
<span className={styles.statusText}>
<Spinner size={14} />
<span className={styles.ellipsis}>{text}</span>
</span>
);
}
/** The branches check, then the creation progress (`statusPanel`). */
function StatusLine() {
const check = useCreatePr((s) => s.check);
const creation = useCreatePr((s) => s.creation);
let checkLine = null;
if (check?.state === "loading") checkLine = <LoadingLabel text={tx("Checking pull request branches")} />;
else if (check?.state === "error")
checkLine = (
<>
<ErrorLabel text={tx("Failed to check pull request branches")} />
<ErrorDetails title={tx("Failed to check pull request branches")} error={check.error} />
<IconButton icon="refresh" size="small" title="Refresh" onClick={() => void refreshBranchesState()} />
</>
);
else if (check?.state === "ok" && check.value.kind === "alreadyExists") {
const n = check.value.number;
checkLine = (
<>
<ErrorLabel text={tx("Pull request already exists")} />
<button type="button" className={styles.textLink} onClick={() => void openPullRequest(n)}>
<T>View</T>
</button>
</>
);
} else if (check?.state === "ok" && check.value.kind === "noChanges")
checkLine = <ErrorLabel text={tx`No changes between '${check.value.base}' and '${check.value.head}'`} />;
let progressLine = null;
if (creation?.kind === "pushing") progressLine = <LoadingLabel text={tx("Pushing a pull request branch")} />;
else if (creation?.kind === "creating") progressLine = <LoadingLabel text={tx("Creating a pull request")} />;
else if (creation?.kind === "metadata") progressLine = <LoadingLabel text={tx("Updating pull request fields")} />;
else if (creation?.kind === "error")
progressLine = (
<>
<ErrorLabel text={tx("Failed to create a pull request")} />
<ErrorDetails title={tx("Failed to create a pull request")} error={creation.error} />
</>
);
if (!checkLine && !progressLine) return null;
return (
<div className={styles.status} role="status">
{checkLine && <div className={styles.statusRow}>{checkLine}</div>}
{progressLine && <div className={styles.statusRow}>{progressLine}</div>}
</div>
);
}
/** `JBOptionButton`: "Create Pull Request" and, behind the arrow, "Create Draft Pull Request". */
function CreateButton() {
const enabled = useCreatePr(canCreate);
const ref = useRef<HTMLDivElement>(null);
const [menu, setMenu] = useState(false);
const options: MenuItem[] = [
{ label: "Create Draft Pull Request", disabled: !enabled, run: () => void createFromForm(true) },
];
return (
<div ref={ref} className={styles.optionButton}>
<Button variant="primary" disabled={!enabled} onClick={() => void createFromForm(false)}>
<T>Create Pull Request</T>
</Button>
<button
type="button"
className={styles.optionArrow}
aria-label={tx("More create options")}
aria-haspopup="menu"
disabled={!enabled}
onClick={() => setMenu(true)}
>
<Icon name="chevron-down" size={14} />
</button>
<Menu
open={menu}
onClose={() => setMenu(false)}
anchor={ref}
items={options}
placement="top-start"
aria-label="Create options"
/>
</div>
);
}
+196
Переглянути файл
@@ -0,0 +1,196 @@
/**
* The merge direction (git4idea's `MergeDirectionComponentFactory`): two link-style
* dropdowns, the base and the head, joined by "←". The base lists the branches of the
* connected repository's remote; the head lists the local branches and the branches of
* the chosen repository (the project's GitHub remotes and forks).
*/
import { useMemo, useState, type KeyboardEvent } from "react";
import { HighlightedText, Icon, List, Popup, SearchField, Select, T, fuzzyMatch, listNavigate } from "@/ui";
import { useTx } from "@/platform/i18n";
import { nameWithOwner, sameRepository, useGhRepository, type GHRemote } from "../data/repository";
import { ChooserPopup } from "../ui/ChooserPopup";
import {
baseText,
headBranchName,
headText,
localBranchesOf,
remoteBranchesOf,
sameHead,
type HeadBranch,
type RemoteBranchRef,
} from "./model";
import { setBaseBranch, setHead, useCreatePr } from "./store";
import styles from "./Create.module.css";
function DropDownLink(props: { text: string | null; label: string; onClick: (el: HTMLElement) => void }) {
const tx = useTx();
return (
<button
type="button"
className={styles.link}
aria-label={tx(props.label)}
title={props.text ?? undefined}
onClick={(e) => props.onClick(e.currentTarget)}
>
<span className={styles.linkText}>{props.text ?? tx("Select…")}</span>
<Icon name="chevron-down" size={12} />
</button>
);
}
export function DirectionSelector() {
const direction = useCreatePr((s) => s.direction);
const branches = useCreatePr((s) => s.branches);
const [open, setOpen] = useState<{ which: "base" | "head"; anchor: HTMLElement } | null>(null);
const baseBranches = useMemo(
() => (direction ? remoteBranchesOf(branches, direction.baseRepo.remote) : []),
[branches, direction],
);
if (!direction) return null;
return (
<div className={styles.direction}>
<DropDownLink
text={baseText(direction)}
label="Base branch"
onClick={(anchor) => setOpen({ which: "base", anchor })}
/>
<span className={styles.arrow} aria-hidden>
←
</span>
<DropDownLink
text={headText(direction)}
label="Head branch"
onClick={(anchor) => setOpen({ which: "head", anchor })}
/>
{open?.which === "base" && (
<ChooserPopup<RemoteBranchRef>
mode="single"
anchor={open.anchor}
items={baseBranches}
getKey={(b) => b.name}
present={(b) => ({ text: b.name, icon: "branch-label" })}
placeholder="Search branches"
aria-label="Base branch"
onChoose={setBaseBranch}
onClose={() => setOpen(null)}
/>
)}
{open?.which === "head" && (
<HeadPopup
anchor={open.anchor}
initialRepo={direction.headRepo ?? direction.baseRepo}
onClose={() => setOpen(null)}
/>
)}
</div>
);
}
type HeadRow = { section: string } | { branch: HeadBranch; text: string; match: number[] | null };
/** The head: the repository (several GitHub remotes only), then its branches, local first. */
function HeadPopup(props: { anchor: HTMLElement; initialRepo: GHRemote; onClose: () => void }) {
const tx = useTx();
const remotes = useGhRepository((s) => s.remotes);
const branches = useCreatePr((s) => s.branches);
const current = useCreatePr((s) => s.direction?.headBranch ?? null);
const [repo, setRepo] = useState(props.initialRepo);
const [filter, setFilter] = useState("");
const [active, setActive] = useState(1);
const rows = useMemo(() => {
const q = filter.trim();
const section = (title: string, list: HeadBranch[]): HeadRow[] => {
const items = list.flatMap((branch) => {
const text = headBranchName(branch);
const match = q ? fuzzyMatch(text, q) : null;
return q && !match ? [] : [{ branch, text, match }];
});
return items.length ? [{ section: title }, ...items] : [];
};
const remote = remoteBranchesOf(branches, repo.remote).map((b): HeadBranch => ({ kind: "remote", ...b }));
return [...section(tx("Local"), localBranchesOf(branches)), ...section(tx("Remote"), remote)];
}, [branches, repo, filter, tx]);
const isSection = (r: HeadRow) => "section" in r;
const index = Math.min(Math.max(active, 0), rows.length - 1);
const choose = (r: HeadRow | undefined) => {
if (!r || "section" in r) return;
setHead(repo, r.branch);
props.onClose();
};
const onKeyDown = (e: KeyboardEvent) => {
const next = listNavigate(e.key, { items: rows, active: index, wrap: true, pageSize: 7, isSection });
if (next !== null && e.key !== "Home" && e.key !== "End") {
e.preventDefault();
setActive(next);
} else if (e.key === "Enter") {
e.preventDefault();
choose(rows[index]);
}
};
return (
<Popup
open
anchor={props.anchor}
placement="bottom-start"
offset={4}
onClose={props.onClose}
role="dialog"
aria-label={tx("Head branch")}
className={styles.headPopup}
>
{remotes.length > 1 && (
<div className={styles.headRepo}>
<span className={styles.headRepoLabel}>
<T>Repository:</T>
</span>
<Select
value={nameWithOwner(repo)}
options={remotes.map((r) => ({ value: nameWithOwner(r), label: `${nameWithOwner(r)} (${r.remote})` }))}
onChange={(v) => {
const next = remotes.find((r) => nameWithOwner(r) === v);
if (next && !sameRepository(next, repo)) setRepo(next);
}}
aria-label="Head repository"
/>
</div>
)}
<div className={styles.headSearch}>
<SearchField
value={filter}
onChange={(v) => {
setFilter(v);
setActive(1);
}}
onKeyDown={onKeyDown}
placeholder={tx("Search branches")}
aria-label="Search branches"
data-autofocus
/>
</div>
<List
items={rows}
getKey={(r) => ("section" in r ? `s:${r.section}` : `${r.branch.kind}:${r.text}`)}
isSection={isSection}
renderSection={(r) => <div className={styles.headSection}>{"section" in r ? r.section : null}</div>}
sectionHeight={22}
activeIndex={index}
onActiveChange={setActive}
onActivate={(r) => choose(r)}
focusable={false}
hoverSelects
rowHeight={24}
className={styles.headList}
aria-label="Branches"
empty={<div className={styles.hint}>{tx("Nothing to show")}</div>}
renderItem={(r, state) =>
"section" in r ? null : (
<div className={styles.headRow} data-active={state.active || undefined}>
<Icon name={sameHead(r.branch, current) ? "current-branch-label" : "branch-label"} size={16} />
<HighlightedText text={r.text} indices={r.match} />
</div>
)
}
/>
</Popup>
);
}
+154
Переглянути файл
@@ -0,0 +1,154 @@
/**
* Reviewers, assignees and labels of the new pull request (`LabeledListComponentsFactory`
* grid): a grey label ("Reviewers:", or "No reviewers" while empty), the chosen items
* with avatars or label colours and an edit button opening a multi-select chooser above.
*/
import { useState, type ReactNode } from "react";
import { IconButton } from "@/ui";
import { useTx } from "@/platform/i18n";
import type { GHLabel, GHPullRequestRequestedReviewer, GHUser } from "../api/types";
import { isTeam, reviewerKey } from "../data/model";
import { usePotentialReviewers, useGhRepository, useRepositoryList } from "../data/repository";
import { Avatar, AVATAR_SIZE } from "../ui/Avatar";
import { ChooserPopup, type ChooserItemPresentation } from "../ui/ChooserPopup";
import { LabelChip, LabelSwatch } from "../ui/chips";
import { selectableReviewers, setAssignees, setLabels, setReviewers, useCreatePr } from "./store";
import styles from "./Create.module.css";
interface ListRowProps<T> {
label: string;
emptyLabel: string;
items: readonly T[];
getKey: (item: T) => string;
renderItem: (item: T) => ReactNode;
chooser: (anchor: HTMLElement, close: () => void) => ReactNode;
}
function ListRow<T>(props: ListRowProps<T>) {
const tx = useTx();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const label = props.items.length ? props.label : props.emptyLabel;
return (
<>
<div className={styles.metaLabel}>{tx(label)}</div>
<div className={styles.metaList}>
{props.items.map((item) => (
<span key={props.getKey(item)} className={styles.metaItem}>
{props.renderItem(item)}
</span>
))}
<IconButton icon="edit" size="small" title="Edit" onClick={(e) => setAnchor(e.currentTarget)} />
{anchor && props.chooser(anchor, () => setAnchor(null))}
</div>
</>
);
}
const userPresentation = (u: GHUser): ChooserItemPresentation => ({
text: u.login,
detail: u.name,
avatarUrl: u.avatarUrl,
});
function reviewerPresentation(r: GHPullRequestRequestedReviewer): ChooserItemPresentation {
if (isTeam(r)) return { text: r.slug, detail: r.teamName, avatarUrl: r.teamAvatarUrl };
return { text: r.login, detail: r.name ?? null, avatarUrl: r.avatarUrl };
}
function UserItem({ text, avatarUrl }: { text: string; avatarUrl?: string | null }) {
return (
<span className={styles.userItem}>
<Avatar url={avatarUrl} name={text} size={AVATAR_SIZE.BASE} />
<span>{text}</span>
</span>
);
}
export function MetadataGrid() {
const reviewers = useCreatePr((s) => s.reviewers);
const assignees = useCreatePr((s) => s.assignees);
const labels = useCreatePr((s) => s.labels);
const me = useGhRepository((s) => s.connection?.currentUser);
const potentialReviewers = usePotentialReviewers();
const potentialAssignees = useRepositoryList("assignees");
const repoLabels = useRepositoryList("labels");
return (
<div className={styles.metadata}>
<ListRow
label="Reviewers:"
emptyLabel="No reviewers"
items={reviewers}
getKey={reviewerKey}
renderItem={(r) => <UserItem {...reviewerPresentation(r)} />}
chooser={(anchor, close) => (
<ChooserPopup<GHPullRequestRequestedReviewer>
mode="multi"
anchor={anchor}
placement="top-start"
items={selectableReviewers(potentialReviewers.items, me)}
loading={potentialReviewers.loading}
error={potentialReviewers.error}
chosen={reviewers}
getKey={reviewerKey}
present={reviewerPresentation}
aria-label="Reviewers"
onClose={(chosen) => {
setReviewers(chosen);
close();
}}
/>
)}
/>
<ListRow
label="Assignees:"
emptyLabel="Unassigned"
items={assignees}
getKey={(u) => u.login}
renderItem={(u) => <UserItem text={u.login} avatarUrl={u.avatarUrl} />}
chooser={(anchor, close) => (
<ChooserPopup<GHUser>
mode="multi"
anchor={anchor}
placement="top-start"
items={potentialAssignees.items}
loading={potentialAssignees.loading}
error={potentialAssignees.error}
chosen={assignees}
getKey={(u) => u.login}
present={userPresentation}
aria-label="Assignees"
onClose={(chosen) => {
setAssignees(chosen);
close();
}}
/>
)}
/>
<ListRow
label="Labels:"
emptyLabel="No labels"
items={labels}
getKey={(l) => l.name}
renderItem={(l) => <LabelChip label={l} />}
chooser={(anchor, close) => (
<ChooserPopup<GHLabel>
mode="multi"
anchor={anchor}
placement="top-start"
items={repoLabels.items}
loading={repoLabels.loading}
error={repoLabels.error}
chosen={labels}
getKey={(l) => l.name}
present={(l) => ({ text: l.name, leading: <LabelSwatch label={l} /> })}
aria-label="Labels"
onClose={(chosen) => {
setLabels(chosen);
close();
}}
/>
)}
/>
</div>
);
}
+43
Переглянути файл
@@ -0,0 +1,43 @@
/** "New Remote Branch for Pull Request": the name to push a local head without a push target to. */
import { useState } from "react";
import { Dialog, Input, T } from "@/ui";
import { openDialog } from "../ui/dialogs";
import styles from "./Create.module.css";
/** Asks for the remote branch name (suggesting `suggested`); null when cancelled. */
export function remoteBranchNameDialog(suggested: string): Promise<string | null> {
return openDialog<string>((done) => <RemoteBranchDialog suggested={suggested} onDone={done} />);
}
function RemoteBranchDialog(props: { suggested: string; onDone: (name: string | null) => void }) {
const [name, setName] = useState(props.suggested);
const valid = !!name.trim();
return (
<Dialog
open
title="New Remote Branch for Pull Request"
onClose={() => props.onDone(null)}
width={420}
buttons={[
{ label: "OK", role: "default", disabled: !valid, onClick: () => props.onDone(name.trim()) },
{ label: "Cancel", role: "cancel" },
]}
>
<form
className={styles.dialogForm}
onSubmit={(e) => {
e.preventDefault();
if (valid) props.onDone(name.trim());
}}
>
<label className={styles.dialogLabel}>
<T>Remote branch name:</T>
</label>
<Input data-autofocus aria-label="Remote branch name" value={name} onChange={(e) => setName(e.target.value)} />
<div className={styles.dialogComment}>
<T>Your changes will be pushed to the remote repository to create the pull request</T>
</div>
</form>
</Dialog>
);
}
+198
Переглянути файл
@@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import type { Branch } from "@/platform/ipc";
import type { GHRemote } from "../data/repository";
import {
baseText,
checkBranches,
cleanUpBranchName,
commitDescription,
commitsRange,
defaultBaseBranch,
defaultDirection,
generateTitle,
headRefName,
headText,
needsPush,
pushTarget,
remoteBranchesOf,
searchHead,
splitRemoteBranch,
trackedHead,
type Direction,
} from "./model";
import { canCreate, selectableReviewers } from "./store";
const branch = (name: string, extra: Partial<Branch> = {}): Branch => ({
name,
remote: false,
current: false,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: null,
...extra,
});
const remote = (name: string, commit?: string) => branch(name, { remote: true, commit });
const origin: GHRemote = { owner: "l0ng-ai", name: "openidea", remote: "origin", url: "" };
const fork: GHRemote = { owner: "me", name: "openidea", remote: "fork", url: "" };
const branches = [
branch("feature", { current: true, upstream: "fork/feature", commit: "a1" }),
branch("local-only", { commit: "b1" }),
branch("tracking-main", { upstream: "origin/main", commit: "c1" }),
remote("origin/HEAD"),
remote("origin/main", "c1"),
remote("origin/master", "m1"),
remote("fork/feature", "a0"),
];
describe("merge direction", () => {
it("splits remote branches by the longest remote name", () => {
expect(splitRemoteBranch("origin/feat/x", ["origin"])).toEqual({ remote: "origin", name: "feat/x" });
expect(splitRemoteBranch("my/fork/x", ["my", "my/fork"])).toEqual({ remote: "my/fork", name: "x" });
expect(splitRemoteBranch("upstream/x", ["origin"])).toBeNull();
});
it("lists a remote's branches without HEAD", () => {
expect(remoteBranchesOf(branches, "origin").map((b) => b.name)).toEqual(["main", "master"]);
});
it("defaults the base to the repository default, then master, then main", () => {
expect(defaultBaseBranch(branches, "origin", "main")).toEqual({ remote: "origin", name: "main" });
expect(defaultBaseBranch(branches, "origin", "develop")).toEqual({ remote: "origin", name: "master" });
expect(defaultBaseBranch([remote("origin/main")], "origin", null)).toEqual({ remote: "origin", name: "main" });
expect(defaultBaseBranch([], "origin", "main")).toBeNull();
});
it("defaults the head to the current branch in its tracked remote's repository", () => {
const d = defaultDirection({
baseRepo: origin,
remotes: [origin, fork],
branches,
currentBranch: "feature",
defaultBranch: "main",
});
expect(d.headRepo).toBe(fork);
expect(d.headBranch).toEqual({ kind: "local", name: "feature" });
expect(d.baseBranch).toEqual({ remote: "origin", name: "main" });
const untracked = defaultDirection({
baseRepo: origin,
remotes: [origin, fork],
branches,
currentBranch: "local-only",
defaultBranch: "main",
});
expect(untracked.headRepo).toBe(origin);
});
it("shows the owner only when the repositories differ", () => {
const same: Direction = {
baseRepo: origin,
baseBranch: { remote: "origin", name: "main" },
headRepo: origin,
headBranch: { kind: "local", name: "feature" },
};
expect(baseText(same)).toBe("openidea:main");
expect(headText(same)).toBe("openidea:feature");
const forked: Direction = { ...same, headRepo: fork, headBranch: { kind: "remote", remote: "fork", name: "x" } };
expect(baseText(forked)).toBe("l0ng-ai/openidea:main");
expect(headText(forked)).toBe("me/openidea:fork/x");
expect(baseText({ ...same, baseBranch: null })).toBeNull();
expect(headText({ ...same, headRepo: null })).toBeNull();
});
it("builds the log range and the API head names", () => {
expect(commitsRange({ remote: "origin", name: "main" }, { kind: "local", name: "f" })).toBe("origin/main..f");
expect(commitsRange({ remote: "origin", name: "main" }, { kind: "remote", remote: "fork", name: "f" })).toBe(
"origin/main..fork/f",
);
expect(headRefName(origin, origin, "f")).toBe("f");
expect(headRefName(origin, fork, "f")).toBe("me:f");
expect(searchHead(origin, "f")).toBe("l0ng-ai:f");
});
});
describe("branches check", () => {
const base = { remote: "origin", name: "main" };
const head = { kind: "local" as const, name: "tracking-main" };
it("finds the tracked branch of the head", () => {
expect(trackedHead(head, branches, ["origin", "fork"])).toEqual(base);
expect(trackedHead({ kind: "local", name: "local-only" }, branches, ["origin"])).toBeNull();
});
it("reports no changes, an existing pull request, or OK", () => {
expect(checkBranches({ base, head, tracked: base, commits: [1], existing: null })).toEqual({
kind: "noChanges",
base: "main",
head: "tracking-main",
});
expect(checkBranches({ base, head, tracked: null, commits: [], existing: 3 }).kind).toBe("noChanges");
expect(checkBranches({ base, head, tracked: null, commits: [1], existing: 3 })).toEqual({
kind: "alreadyExists",
number: 3,
});
expect(checkBranches({ base, head, tracked: null, commits: [1], existing: null })).toEqual({ kind: "ok" });
});
it("enables Create only for OK while nothing runs or after a failure", () => {
const okCheck = { state: "ok" as const, value: { kind: "ok" as const } };
expect(canCreate({ check: okCheck, creation: null })).toBe(true);
expect(canCreate({ check: okCheck, creation: { kind: "error", error: "x" } })).toBe(true);
expect(canCreate({ check: okCheck, creation: { kind: "pushing" } })).toBe(false);
expect(canCreate({ check: { state: "loading" }, creation: null })).toBe(false);
expect(canCreate({ check: { state: "ok", value: { kind: "alreadyExists", number: 1 } }, creation: null })).toBe(
false,
);
});
});
describe("push target", () => {
it("is the tracked branch on the head's remote", () => {
expect(pushTarget(branches, "feature", "fork", ["origin", "fork"])).toEqual({ remote: "fork", name: "feature" });
expect(pushTarget(branches, "feature", "origin", ["origin", "fork"])).toBeNull();
expect(pushTarget(branches, "local-only", "origin", ["origin", "fork"])).toBeNull();
});
it("needs a push when the tips differ or the remote branch is missing", () => {
expect(needsPush(branches, "feature", { remote: "fork", name: "feature" })).toBe(true);
expect(needsPush(branches, "tracking-main", { remote: "origin", name: "main" })).toBe(false);
expect(needsPush(branches, "local-only", { remote: "origin", name: "local-only" })).toBe(true);
});
it("cleans up branch names like GitRefNameValidator", () => {
expect(cleanUpBranchName(" my feature: v2? ")).toBe("my_feature__v2_");
expect(cleanUpBranchName("a..b~c^d")).toBe("a_b_c_d");
expect(cleanUpBranchName("/x.lock")).toBe("x_lock");
});
});
describe("text", () => {
it("titles with the only commit's subject, else the branch", () => {
const head = { kind: "local" as const, name: "feature" };
expect(generateTitle([{ summary: "Fix it" }], head)).toBe("Fix it");
expect(generateTitle([{ summary: "a" }, { summary: "b" }], head)).toBe("feature");
expect(generateTitle([], { kind: "remote", remote: "fork", name: "x" })).toBe("fork/x");
});
it("takes the description paragraph of a commit message", () => {
expect(commitDescription("Subject\n\nBody line\nmore\n\nTrailer: x")).toBe("Body line\nmore");
expect(commitDescription("Subject")).toBe("");
});
it("leaves the current user out of the reviewers", () => {
const me = { __typename: "User" as const, id: "1", login: "me", url: "", avatarUrl: "", name: null };
const other = { ...me, id: "2", login: "other" };
const team = {
__typename: "Team" as const,
id: "1",
slug: "core",
url: "",
teamAvatarUrl: null,
teamName: "Core",
combinedSlug: "org/core",
};
expect(selectableReviewers([me, other, team], me)).toEqual([other, team]);
});
});
+216
Переглянути файл
@@ -0,0 +1,216 @@
/**
* Pure logic of the New Pull Request tab (IntelliJ's `GHPRCreateViewModel` and
* `GHPRCreateMergeDirectionModel`): the merge direction (base and head repository and
* branch) and how it is shown, the default branches, the branches check, the generated
* title and the push target of a local head.
*/
import type { Branch, Commit } from "@/platform/ipc";
import type { GHRepositoryCoordinates } from "../api/types";
import type { GHRemote } from "../data/repository";
/** A branch of a remote (`origin/main` is `{ remote: "origin", name: "main" }`). */
export interface RemoteBranchRef {
remote: string;
name: string;
}
/** The head: a local branch or a remote one (`GitLocalBranch` / `GitRemoteBranch`). */
export type HeadBranch = { kind: "local"; name: string } | ({ kind: "remote" } & RemoteBranchRef);
/** `BranchesState`: the base is always a branch of the connected repository. */
export interface Direction {
baseRepo: GHRemote;
baseBranch: RemoteBranchRef | null;
headRepo: GHRemote | null;
headBranch: HeadBranch | null;
}
const sameCoordinates = (a: GHRepositoryCoordinates, b: GHRepositoryCoordinates) =>
a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
/** `origin/main` → `{ remote: "origin", name: "main" }`, the longest matching remote name winning. */
export function splitRemoteBranch(full: string, remotes: readonly string[]): RemoteBranchRef | null {
const remote = [...remotes].filter((r) => r && full.startsWith(`${r}/`)).sort((a, b) => b.length - a.length)[0];
return remote ? { remote, name: full.slice(remote.length + 1) } : null;
}
export const remoteBranchFullName = (b: RemoteBranchRef) => `${b.remote}/${b.name}`;
/** What git calls the head (`feature`, `origin/feature`). */
export const headBranchName = (b: HeadBranch) => (b.kind === "local" ? b.name : remoteBranchFullName(b));
export const sameRemoteBranch = (a: RemoteBranchRef | null | undefined, b: RemoteBranchRef | null | undefined) =>
!!a && !!b && a.remote === b.remote && a.name === b.name;
export function sameHead(a: HeadBranch | null, b: HeadBranch | null): boolean {
if (!a || !b) return a === b;
if (a.kind === "local" || b.kind === "local") return a.kind === b.kind && a.name === b.name;
return sameRemoteBranch(a, b);
}
/** The branches of `remote` (its `HEAD` pointer left out), sorted by name. */
export function remoteBranchesOf(branches: readonly Branch[], remote: string): RemoteBranchRef[] {
return branches
.filter((b) => b.remote)
.flatMap((b) => {
const ref = splitRemoteBranch(b.name, [remote]);
return ref && ref.name !== "HEAD" ? [ref] : [];
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/** Local branches, sorted by name. */
export function localBranchesOf(branches: readonly Branch[]): HeadBranch[] {
return branches
.filter((b) => !b.remote)
.map((b): HeadBranch => ({ kind: "local", name: b.name }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* `getDefaultRemoteBranch`: the repository's default branch on the base remote, else
* `master`, else `main`.
*/
export function defaultBaseBranch(
branches: readonly Branch[],
remote: string,
defaultBranch: string | null | undefined,
): RemoteBranchRef | null {
const names = new Set(remoteBranchesOf(branches, remote).map((b) => b.name));
const name = [defaultBranch, "master", "main"].find((n): n is string => !!n && names.has(n));
return name ? { remote, name } : null;
}
/** The remote branch a local branch tracks (`upstream`), split by the known remotes. */
export function upstreamOf(
branches: readonly Branch[],
local: string,
remotes: readonly string[],
): RemoteBranchRef | null {
const upstream = branches.find((b) => !b.remote && b.name === local)?.upstream;
return upstream ? splitRemoteBranch(upstream, remotes) : null;
}
/** The remote branch the head is or tracks (none for an untracked local branch). */
export function trackedHead(
head: HeadBranch,
branches: readonly Branch[],
remotes: readonly string[],
): RemoteBranchRef | null {
return head.kind === "remote" ? head : upstreamOf(branches, head.name, remotes);
}
/**
* The default direction: base the default branch of the connected repository's remote,
* head the current branch in the repository of its tracked remote (else the base one).
*/
export function defaultDirection(opts: {
baseRepo: GHRemote;
remotes: readonly GHRemote[];
branches: readonly Branch[];
currentBranch: string | null;
defaultBranch: string | null | undefined;
}): Direction {
const { baseRepo, remotes, branches, currentBranch } = opts;
const names = remotes.map((r) => r.remote);
const tracked = currentBranch ? upstreamOf(branches, currentBranch, names) : null;
const headRepo = (tracked && remotes.find((r) => r.remote === tracked.remote)) || baseRepo;
return {
baseRepo,
baseBranch: defaultBaseBranch(branches, baseRepo.remote, opts.defaultBranch),
headRepo,
headBranch: currentBranch ? { kind: "local", name: currentBranch } : null,
};
}
const repoText = (r: GHRepositoryCoordinates, showOwner: boolean) => (showOwner ? `${r.owner}/${r.name}` : r.name);
/** The base as the direction selector shows it: `repo:branch`, `owner/repo:branch` when the head's repository differs. */
export function baseText(d: Direction): string | null {
if (!d.baseBranch) return null;
const showOwner = !!d.headRepo && !sameCoordinates(d.headRepo, d.baseRepo);
return `${repoText(d.baseRepo, showOwner)}:${d.baseBranch.name}`;
}
/** The head as the direction selector shows it: `repo:branch` (`origin/branch` for a remote branch). */
export function headText(d: Direction): string | null {
if (!d.headBranch || !d.headRepo) return null;
const showOwner = !sameCoordinates(d.headRepo, d.baseRepo);
return `${repoText(d.headRepo, showOwner)}:${headBranchName(d.headBranch)}`;
}
/** `git log base..head`. */
export const commitsRange = (base: RemoteBranchRef, head: HeadBranch) =>
`${remoteBranchFullName(base)}..${headBranchName(head)}`;
/** `generateTitle`: the subject of the only commit, else the branch name. */
export function generateTitle(commits: readonly Pick<Commit, "summary">[], head: HeadBranch): string {
return commits.length === 1 ? commits[0]!.summary : headBranchName(head);
}
/** `headRefName` of `createPullRequest`: `owner:branch` for a branch of another repository (a fork). */
export function headRefName(baseRepo: GHRepositoryCoordinates, headRepo: GHRepositoryCoordinates, branch: string) {
return sameCoordinates(baseRepo, headRepo) ? branch : `${headRepo.owner}:${branch}`;
}
/** `head` of the REST pull request search: always `owner:branch`. */
export const searchHead = (headRepo: GHRepositoryCoordinates, branch: string) => `${headRepo.owner}:${branch}`;
/** `GitPushUtil.findPushTarget`: the tracked branch when it is on the head's remote. */
export function pushTarget(
branches: readonly Branch[],
local: string,
headRemote: string,
remotes: readonly string[],
): RemoteBranchRef | null {
const upstream = upstreamOf(branches, local, remotes);
return upstream?.remote === headRemote ? upstream : null;
}
/** Whether the local branch differs from its push target (or the target does not exist yet). */
export function needsPush(branches: readonly Branch[], local: string, target: RemoteBranchRef): boolean {
const localTip = branches.find((b) => !b.remote && b.name === local)?.commit;
const remoteTip = branches.find((b) => b.remote && b.name === remoteBranchFullName(target))?.commit;
return !localTip || !remoteTip || localTip !== remoteTip;
}
const ILLEGAL_REF_CHARS = /[\u0000- \u007f~^:?*[\\]|\.\.|@\{/g; // eslint-disable-line no-control-regex
/** `GitRefNameValidator.cleanUpBranchName`: characters git refuses become `_`. */
export function cleanUpBranchName(name: string): string {
return name
.trim()
.replace(ILLEGAL_REF_CHARS, "_")
.replace(/^[./-]+|[./]+$/g, "")
.replace(/\.lock$/, "_lock");
}
/** `BranchesCheckResult`. */
export type BranchesCheckResult =
| { kind: "noChanges"; base: string; head: string }
| { kind: "alreadyExists"; number: number }
| { kind: "ok" };
/**
* `checkBranches`: no changes when the head tracks the base or has no commits over
* it, else an open pull request of the head, else OK.
*/
export function checkBranches(opts: {
base: RemoteBranchRef;
head: HeadBranch;
tracked: RemoteBranchRef | null;
commits: readonly unknown[];
existing: number | null;
}): BranchesCheckResult {
const { base, head } = opts;
const noChanges = { kind: "noChanges" as const, base: base.name, head: headBranchName(head) };
if (sameRemoteBranch(base, opts.tracked)) return noChanges;
if (opts.commits.length === 0) return noChanges;
if (opts.existing !== null) return { kind: "alreadyExists", number: opts.existing };
return { kind: "ok" };
}
/** The description part of a commit message (`fullMessage.split("\n\n")[1]`). */
export function commitDescription(message: string): string {
return message.split("\n\n")[1]?.trim() ?? "";
}
+446
Переглянути файл
@@ -0,0 +1,446 @@
/**
* State of the New Pull Request tab (IntelliJ's `GHPRCreateViewModel`), kept while the
* tab is open (switching tabs keeps the form) and dropped when it closes:
*
* - the merge direction, its commits (`git log base..head`), the branches check and the
* changes of all commits (three-dot diff) or of the selected one, all computed locally;
* - the title (the only commit's subject or the branch name until edited), the
* description (the repository's template) and the reviewers, assignees and labels;
* - creation: push a local head first (asking for the remote branch name when it has
* none), `createPullRequest`, then the metadata; on success the pull request opens and
* the tab closes.
*/
import { create } from "zustand";
import { commitFileDiffData, type DiffData } from "@/features/vcs/diffInput";
import { lastRemoteFailure, runRemote } from "@/features/vcs/ops";
import { useVcs } from "@/features/vcs/store";
import type { Branch, ChangeKind, Commit } from "@/platform/ipc";
import { call, commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { tx } from "@/platform/i18n";
import { errorMessage } from "../api/client";
import * as api from "../api/requests";
import type { GHLabel, GHPullRequestRequestedReviewer, GHUser } from "../api/types";
import { isTeam } from "../data/model";
import { createPullRequest } from "../data/operations";
import { loadPullRequestTemplate, requireConnection, sameRepository, useGhRepository } from "../data/repository";
import type { GHRemote } from "../data/repository";
import { refreshCurrentBranchPullRequest } from "../reviewMode/state";
import { openPullRequest } from "../toolwindow/open";
import { closeTab, useGhTabs } from "../toolwindow/store";
import {
checkBranches,
cleanUpBranchName,
commitsRange,
defaultDirection,
generateTitle,
headBranchName,
headRefName,
needsPush,
pushTarget,
remoteBranchFullName,
sameHead,
sameRemoteBranch,
searchHead,
trackedHead,
type BranchesCheckResult,
type Direction,
type HeadBranch,
type RemoteBranchRef,
} from "./model";
import { remoteBranchNameDialog } from "./RemoteBranchDialog";
export type Loadable<T> = { state: "loading" } | { state: "error"; error: string } | { state: "ok"; value: T };
/** A changed file of the preview, with its diff. */
export interface CreateChange {
/** Repository-relative path (after the change). */
path: string;
oldPath: string | null;
kind: ChangeKind;
diff: DiffData;
}
/** `CreationState` (CollectingData, Pushing, CallingAPI, SettingMetadata, Created, Error). */
export type CreationState =
| { kind: "collecting" | "pushing" | "creating" | "metadata" | "created" }
| { kind: "error"; error: string };
interface CreateState {
/** Initialized (branches read, direction chosen). */
ready: boolean;
initError: string | null;
/** Local and remote branches of the repository. */
branches: Branch[];
direction: Direction | null;
/** `git log base..head`, newest first; null until base and head are chosen. */
commits: Loadable<Commit[]> | null;
check: Loadable<BranchesCheckResult> | null;
/** The commit whose changes are shown; null: all commits. */
selectedCommit: string | null;
changes: Loadable<CreateChange[]> | null;
title: string;
/** The user typed a title: it no longer follows the commits. */
titleEdited: boolean;
description: string;
descriptionEdited: boolean;
templateLoading: boolean;
reviewers: GHPullRequestRequestedReviewer[];
assignees: GHUser[];
labels: GHLabel[];
creation: CreationState | null;
}
const initial: CreateState = {
ready: false,
initError: null,
branches: [],
direction: null,
commits: null,
check: null,
selectedCommit: null,
changes: null,
title: "",
titleEdited: false,
description: "",
descriptionEdited: false,
templateLoading: false,
reviewers: [],
assignees: [],
labels: [],
creation: null,
};
export const useCreatePr = create<CreateState>(() => ({ ...initial }));
/** Bumped by every reset: late results for a dropped form are ignored. */
let generation = 0;
/** Bumped by every direction change and refresh: late results of older checks are dropped. */
let checkSeq = 0;
let changesSeq = 0;
/** The repository's root, which the preview's repository-relative paths are relative to. */
function repoRoot(): string | null {
return useVcs.getState().roots[0] ?? useProject.getState().project?.root ?? null;
}
const ok = <T>(value: T): Loadable<T> => ({ state: "ok", value });
const failed = (e: unknown): Loadable<never> => ({ state: "error", error: errorMessage(e) });
const remoteNames = () => useGhRepository.getState().remotes.map((r) => r.remote);
/** The connected repository's GitHub remote (the base). */
function baseRemote(): GHRemote {
const { coordinates } = requireConnection();
const remotes = useGhRepository.getState().remotes;
return remotes.find((r) => sameRepository(r, coordinates)) ?? { ...coordinates, remote: "origin", url: "" };
}
/** Reads the branches and picks the default direction; starts loading the template. */
export async function initCreate(): Promise<void> {
if (useCreatePr.getState().ready) return;
const e = ++generation;
try {
const { repository } = requireConnection();
const [branches, info] = await Promise.all([
call(commands.vcsBranches(null)),
call(commands.vcsBranchInfo(null)).catch(() => null),
]);
if (e !== generation) return;
const direction = defaultDirection({
baseRepo: baseRemote(),
remotes: useGhRepository.getState().remotes,
branches,
currentBranch: info?.head ?? null,
defaultBranch: repository.defaultBranchRef?.name,
});
useCreatePr.setState({ ready: true, initError: null, branches, direction, templateLoading: true });
void loadPullRequestTemplate().then((template) => {
if (e !== generation) return;
const s = useCreatePr.getState();
useCreatePr.setState({ templateLoading: false, ...(s.descriptionEdited ? {} : { description: template ?? "" }) });
});
reloadBranchesState();
} catch (err) {
if (e === generation) useCreatePr.setState({ initError: errorMessage(err) });
}
}
/** Drops the form (the tab closed, another repository). */
export function resetCreate() {
generation++;
checkSeq++;
changesSeq++;
useCreatePr.setState({ ...initial });
}
export function setBaseBranch(branch: RemoteBranchRef) {
const d = useCreatePr.getState().direction;
if (!d || sameRemoteBranch(d.baseBranch, branch)) return;
useCreatePr.setState({ direction: { ...d, baseBranch: branch } });
reloadBranchesState();
}
export function setHead(repo: GHRemote, branch: HeadBranch) {
const d = useCreatePr.getState().direction;
if (!d || (d.headRepo && sameRepository(d.headRepo, repo) && sameHead(d.headBranch, branch))) return;
useCreatePr.setState({ direction: { ...d, headRepo: repo, headBranch: branch } });
reloadBranchesState();
}
/** The Refresh of a failed check: reads the branches again and checks them. */
export async function refreshBranchesState(): Promise<void> {
const branches = await call(commands.vcsBranches(null)).catch(() => null);
if (branches) useCreatePr.setState({ branches });
reloadBranchesState();
}
const tip = (branches: readonly Branch[], name: string) => branches.find((b) => b.name === name)?.commit;
/** The repository changed: reloads the commits when the base or head moved (`infoStateIn`). */
export async function onRepositoryChanged(): Promise<void> {
const s = useCreatePr.getState();
if (!s.ready || !s.direction) return;
const branches = await call(commands.vcsBranches(null)).catch(() => null);
if (!branches) return;
const { baseBranch, headBranch } = useCreatePr.getState().direction ?? {};
const before = useCreatePr.getState().branches;
useCreatePr.setState({ branches });
const moved = (name: string) => tip(before, name) !== tip(branches, name);
if (
(baseBranch && moved(remoteBranchFullName(baseBranch))) ||
(headBranch && moved(headBranchName(headBranch))) ||
(headBranch?.kind === "local" &&
before.find((b) => b.name === headBranch.name)?.upstream !==
branches.find((b) => b.name === headBranch.name)?.upstream)
)
reloadBranchesState();
}
/** `BranchesStateWithProgress`: the commits, the open pull request of the head and the check. */
function reloadBranchesState() {
const e = ++checkSeq;
const { direction: d, branches } = useCreatePr.getState();
const base = d?.baseBranch;
const head = d?.headBranch;
const headRepo = d?.headRepo;
if (!base || !head || !headRepo) {
useCreatePr.setState({ commits: null, check: null, changes: null, selectedCommit: null });
return;
}
const tracked = trackedHead(head, branches, remoteNames());
const trackingBase = sameRemoteBranch(base, tracked);
useCreatePr.setState({
commits: { state: "loading" },
changes: { state: "loading" },
selectedCommit: null,
check: trackingBase
? ok(checkBranches({ base, head, tracked, commits: [], existing: null }))
: { state: "loading" },
});
const existing = tracked
? api
.findOpenPullRequestsByHead(searchHead(headRepo, tracked.name), base.name)
.then((prs) => prs[0]?.number ?? null)
: Promise.resolve(null);
existing.catch(() => undefined);
const commits = call(
commands.vcsLog(null, {
branch: null,
path: null,
author: null,
text: null,
range: commitsRange(base, head),
skip: 0,
limit: 10_000,
}),
);
void commits.then(
(list) => {
if (e !== checkSeq) return;
const s = useCreatePr.getState();
useCreatePr.setState({ commits: ok(list), ...(s.titleEdited ? {} : { title: generateTitle(list, head) }) });
void loadChanges();
},
(err) => {
if (e === checkSeq) useCreatePr.setState({ commits: failed(err), changes: null });
},
);
if (trackingBase) return;
void Promise.all([commits, existing]).then(
([list, number]) => {
if (e === checkSeq)
useCreatePr.setState({ check: ok(checkBranches({ base, head, tracked, commits: list, existing: number })) });
},
(err) => {
if (e === checkSeq) useCreatePr.setState({ check: failed(err) });
},
);
}
/** Shows the changes of commit `id` (null: all commits). */
export function selectCommit(id: string | null) {
if (useCreatePr.getState().selectedCommit === id) return;
useCreatePr.setState({ selectedCommit: id });
void loadChanges();
}
/** Diff of a file of all commits: the merge base ↔ the head. */
function rangeDiffData(
file: { path: string; oldPath: string | null; kind: ChangeKind },
root: string | null,
base: { rev: string; label: string },
head: { rev: string; label: string },
): DiffData {
const abs = (rel: string) => (root ? `${root}/${rel}` : rel);
return {
path: abs(file.path),
left:
file.kind === "added" || file.kind === "untracked"
? { type: "text", text: "", label: tx("(does not exist)") }
: { type: "rev", rev: base.rev, path: file.oldPath ? abs(file.oldPath) : undefined, label: base.label },
right: file.kind === "deleted" ? { type: "text", text: "", label: tx("(deleted)") } : { type: "rev", ...head },
};
}
/** `getDiff`: the three-dot diff of all commits, or the selected commit's changes. */
async function loadChanges() {
const seq = ++changesSeq;
const s = useCreatePr.getState();
const d = s.direction;
if (s.commits?.state !== "ok" || !d?.baseBranch || !d.headBranch) return;
const list = s.commits.value;
if (!list.length) {
useCreatePr.setState({ changes: null });
return;
}
useCreatePr.setState({ changes: { state: "loading" } });
const root = repoRoot();
try {
let changes: CreateChange[];
const commit = s.selectedCommit ? list.find((c) => c.id === s.selectedCommit) : null;
if (commit) {
const files = await call(commands.vcsCommitFiles(null, commit.id));
changes = files.map((f) => ({ ...f, diff: commitFileDiffData({ ...commit, repo: root }, f) }));
} else {
const head = list[0]!.id;
const baseName = remoteBranchFullName(d.baseBranch);
const mergeBase = await call(commands.vcsMergeBase(null, baseName, head));
if (!mergeBase) throw new Error(tx`Unrelated branches ${baseName} ${headBranchName(d.headBranch)}`);
const files = await call(commands.vcsDiffRevisions(null, mergeBase, head));
const baseSide = { rev: mergeBase, label: `${d.baseBranch.name} (${mergeBase.slice(0, 8)})` };
const headSide = { rev: head, label: `${headBranchName(d.headBranch)} (${head.slice(0, 8)})` };
changes = files.map((f) => ({ ...f, diff: rangeDiffData(f, root, baseSide, headSide) }));
}
if (seq === changesSeq) useCreatePr.setState({ changes: ok(changes) });
} catch (err) {
if (seq === changesSeq) useCreatePr.setState({ changes: failed(err) });
}
}
export function setTitle(title: string) {
useCreatePr.setState({ title, titleEdited: true });
}
export function setDescription(description: string) {
useCreatePr.setState({ description, descriptionEdited: true });
}
export function setReviewers(reviewers: GHPullRequestRequestedReviewer[]) {
useCreatePr.setState({ reviewers });
}
export function setAssignees(assignees: GHUser[]) {
useCreatePr.setState({ assignees });
}
export function setLabels(labels: GHLabel[]) {
useCreatePr.setState({ labels });
}
/** Create is enabled when the check is OK and nothing runs (or the last attempt failed). */
export function canCreate(s: Pick<CreateState, "check" | "creation">): boolean {
return s.check?.state === "ok" && s.check.value.kind === "ok" && (s.creation === null || s.creation.kind === "error");
}
/** Pushes a local head when needed; resolves with the remote branch name, null when cancelled. */
async function pushHead(head: Extract<HeadBranch, { kind: "local" }>, headRepo: GHRemote): Promise<string | null> {
const branches = useCreatePr.getState().branches;
let target = pushTarget(branches, head.name, headRepo.remote, remoteNames());
if (!target) {
const name = await remoteBranchNameDialog(head.name);
if (name === null) return null;
target = { remote: headRepo.remote, name: cleanUpBranchName(name) };
}
if (needsPush(branches, head.name, target)) {
useCreatePr.setState({ creation: { kind: "pushing" } });
const pushed = await runRemote(
{
op: "pushBranch",
branch: head.name,
revision: null,
force_with_lease: false,
target: target.name,
remote: target.remote,
},
`${head.name} → ${remoteBranchFullName(target)}`,
[null],
);
if (!pushed) {
const error = lastRemoteFailure();
// Cancelled: back to the form.
if (!error) return null;
throw new Error(tx`Push failed: ${error}`);
}
const after = await call(commands.vcsBranches(null)).catch(() => null);
if (after) useCreatePr.setState({ branches: after });
}
return target.name;
}
/** "Create Pull Request" / "Create Draft Pull Request". */
export async function createFromForm(draft: boolean): Promise<void> {
const s = useCreatePr.getState();
const d = s.direction;
if (!canCreate(s) || !d?.baseBranch || !d.headBranch || !d.headRepo) return;
const { baseBranch, headBranch, headRepo } = d;
const e = generation;
useCreatePr.setState({ creation: { kind: "collecting" } });
try {
const remoteName = headBranch.kind === "remote" ? headBranch.name : await pushHead(headBranch, headRepo);
if (remoteName === null) {
useCreatePr.setState({ creation: null });
return;
}
useCreatePr.setState({ creation: { kind: "creating" } });
const { coordinates } = requireConnection();
const pr = await createPullRequest({
base: baseBranch.name,
head: headRefName(coordinates, headRepo, remoteName),
title: s.title,
body: s.description,
draft,
reviewers: s.reviewers,
assignees: s.assignees.map((u) => u.login),
labels: s.labels.map((l) => l.name),
onSettingMetadata: () => useCreatePr.setState({ creation: { kind: "metadata" } }),
});
useCreatePr.setState({ creation: { kind: "created" } });
void refreshCurrentBranchPullRequest();
await openPullRequest(pr.number);
closeTab("create");
} catch (err) {
if (e === generation) useCreatePr.setState({ creation: { kind: "error", error: errorMessage(err) } });
}
}
/** The reviewers one can pick: not the current user. */
export function selectableReviewers(items: readonly GHPullRequestRequestedReviewer[], me: GHUser | null | undefined) {
return me ? items.filter((r) => isTeam(r) || r.id !== me.id) : [...items];
}
// The form lives as long as its tab.
useGhTabs.subscribe((t, prev) => {
const open = t.tabs.some((x) => x.kind === "create");
if (!open && prev.tabs.some((x) => x.kind === "create")) resetCreate();
});