дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
696377acc2
коміт
059fd8a69b
192 files changed
+23668
-2076
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача