дзеркало
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,103 @@
|
||||
/**
|
||||
* The head branch widget (`CodeReviewDetailsBranchComponentFactory`): the branch icon
|
||||
* (the current-branch icon once the head branch is checked out) and a dropdown link with
|
||||
* the head branch (`owner:branch` for forks and equal names). Its popup lists Checkout,
|
||||
* Open in Worktree (when the branch is not checked out), Show in Git Log and Copy Branch
|
||||
* Name, with the "{base} ← {head}" note below.
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Icon, List, Popup } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { GHPullRequest } from "../api/types";
|
||||
import { checkoutPullRequest, openInWorktree } from "../data/local";
|
||||
import { copyBranchName, showInGitLog } from "./flow";
|
||||
import { headBranchLabel } from "./model";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
type BranchAction = "checkout" | "worktree" | "log" | "copy";
|
||||
|
||||
export function BranchWidget({
|
||||
pr,
|
||||
checkedOut,
|
||||
headRemote,
|
||||
hasRemoteBranch = true,
|
||||
}: {
|
||||
pr: GHPullRequest;
|
||||
checkedOut: boolean;
|
||||
/** The local remote of the head repository, for Show in Git Log. */
|
||||
headRemote: string | null;
|
||||
/** GitHub always has the head branch remotely (`CodeReviewBranches.hasRemoteBranch`). */
|
||||
hasRemoteBranch?: boolean;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const anchor = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const source = headBranchLabel(pr);
|
||||
const actions: BranchAction[] = ["checkout", ...(checkedOut ? [] : ["worktree" as const]), "log", "copy"];
|
||||
const label: Record<BranchAction, string> = {
|
||||
checkout: hasRemoteBranch ? tx("Checkout") : tx("Checkout as detached HEAD"),
|
||||
worktree: tx("Open in Worktree"),
|
||||
log: tx("Show in Git Log"),
|
||||
copy: tx("Copy Branch Name"),
|
||||
};
|
||||
const run = (a: BranchAction) => {
|
||||
setOpen(false);
|
||||
if (a === "checkout") void checkoutPullRequest(pr.number);
|
||||
else if (a === "worktree") void openInWorktree(pr.number);
|
||||
else if (a === "log") void showInGitLog(pr, headRemote);
|
||||
else void copyBranchName(source);
|
||||
};
|
||||
return (
|
||||
<div ref={anchor} className={styles.branch}>
|
||||
<button type="button" className={styles.branchIcon} aria-label={tx("Branches")} onClick={() => setOpen(true)}>
|
||||
<Icon name={checkedOut ? "current-branch-label" : "branch-label"} size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.link, styles.dropdownLink, styles.branchLink)}
|
||||
title={source}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className={styles.ellipsis}>{source}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
{open && (
|
||||
<Popup
|
||||
open
|
||||
anchor={anchor}
|
||||
placement="bottom-start"
|
||||
onClose={() => setOpen(false)}
|
||||
aria-label={tx("Branches")}
|
||||
className={styles.branchPopup}
|
||||
>
|
||||
<List
|
||||
items={actions}
|
||||
getKey={(a) => a}
|
||||
onActivate={(a) => run(a)}
|
||||
hoverSelects
|
||||
rowHeight={24}
|
||||
aria-label={tx("Branches")}
|
||||
renderItem={(a, state) => (
|
||||
<div className={styles.popupRow} data-active={state.active || undefined}>
|
||||
{label[a]}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.advertiser}>
|
||||
{hasRemoteBranch ? (
|
||||
tx`${pr.baseRefName} ← ${source}`
|
||||
) : (
|
||||
<>
|
||||
<Icon name="warning" size={16} />
|
||||
{tx("Remote branches are not available for private forks")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* The changes tree (`CodeReviewChangeListComponentFactory` with
|
||||
* `CodeReviewProgressRenderer`): file names in their VCS status colour, grouped by
|
||||
* directory or flat, and on the right of files and collapsed folders the unresolved
|
||||
* thread counter (unread icon while the file is not viewed), the unread dot of files not
|
||||
* viewed, and — for the "All commits" list — a viewed checkbox shown on hover and kept
|
||||
* checked once viewed. Double-click / Enter opens the diff; the context menu is
|
||||
* `Github.PullRequest.Changes.Popup`.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { dirKeys, type FileTreeNode } from "@/features/vcs/fileTree";
|
||||
import { baseName, dirName, fileCount, KIND_COLOR } from "@/features/vcs/model";
|
||||
import { currentRepoRoot } from "@/features/vcs/store";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Icon, Tree, fileIconName, showContextMenu, T, type MenuItem, type TreeHandle } from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import type { GHCommitFile, GHPullRequestFileViewedState, GHPullRequestReviewThread } from "../api/types";
|
||||
import { openPullRequestFileDiff } from "../data/local";
|
||||
import { setViewed } from "../data/operations";
|
||||
import { provideGithubHandler } from "../toolwindow/handlers";
|
||||
import { openCommitFileDiff } from "./flow";
|
||||
import {
|
||||
changeKind,
|
||||
changesTree,
|
||||
isViewed,
|
||||
progressOf,
|
||||
selectedFiles,
|
||||
unresolvedThreadsByPath,
|
||||
viewedToggleText,
|
||||
type ProgressState,
|
||||
} from "./model";
|
||||
import { setGroupByDirectory, useChangesGrouping } from "./state";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
type Node = FileTreeNode<GHCommitFile>;
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set();
|
||||
|
||||
export interface ChangesTreeProps {
|
||||
number: number;
|
||||
files: readonly GHCommitFile[];
|
||||
/** The commit whose changes these are; null for all commits (the cumulative list, with viewed state). */
|
||||
commitSha: string | null;
|
||||
viewedState: Record<string, GHPullRequestFileViewedState> | undefined;
|
||||
threads: readonly GHPullRequestReviewThread[] | undefined;
|
||||
/** The head branch is checked out (its files can be edited). */
|
||||
checkedOut: boolean;
|
||||
}
|
||||
|
||||
export function ChangesTree({ number, files, commitSha, viewedState, threads, checkedOut }: ChangesTreeProps) {
|
||||
const tx = useTx();
|
||||
const grouped = useChangesGrouping((s) => s.byDirectory);
|
||||
const hasViewedState = commitSha === null;
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
const roots = useMemo(() => changesTree(files, grouped), [files, grouped]);
|
||||
const allDirs = useMemo(() => dirKeys(roots), [roots]);
|
||||
// Everything starts expanded; collapsing is remembered while the list stays the same.
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(NONE);
|
||||
const expanded = useMemo(() => new Set(allDirs.filter((k) => !collapsed.has(k))), [allDirs, collapsed]);
|
||||
const setExpanded = (next: ReadonlySet<string>) => setCollapsed(new Set(allDirs.filter((k) => !next.has(k))));
|
||||
const [selection, setSelection] = useState<ReadonlySet<string>>(NONE);
|
||||
|
||||
const discussions = useMemo(() => unresolvedThreadsByPath(threads ?? []), [threads]);
|
||||
const leaf = useMemo(
|
||||
() =>
|
||||
(f: GHCommitFile): ProgressState => ({
|
||||
isRead: hasViewedState ? isViewed(viewedState?.[f.filename]) : true,
|
||||
discussions: discussions.get(f.filename) ?? 0,
|
||||
}),
|
||||
[hasViewedState, viewedState, discussions],
|
||||
);
|
||||
// Until the threads and the viewed state load, no state is shown (`isLoading`).
|
||||
const loading = threads === undefined || (hasViewedState && viewedState === undefined);
|
||||
|
||||
const openDiff = (f: GHCommitFile) =>
|
||||
void (commitSha ? openCommitFileDiff(number, commitSha, f) : openPullRequestFileDiff(number, f.filename));
|
||||
const jumpToSource = (f: GHCommitFile) => {
|
||||
const root = currentRepoRoot();
|
||||
if (root) void editorService().openFile(`${root.replace(/[\\/]+$/, "")}/${f.filename}`, { focus: true });
|
||||
};
|
||||
const canEdit = (f: GHCommitFile) =>
|
||||
f.status !== "removed" && (checkedOut || (f.status !== "added" && f.status !== "copied"));
|
||||
|
||||
const toggle = (chosen: readonly GHCommitFile[]) => {
|
||||
if (!hasViewedState || !chosen.length) return;
|
||||
const allViewed = chosen.every((f) => leaf(f).isRead);
|
||||
void setViewed(
|
||||
number,
|
||||
chosen.map((f) => f.filename),
|
||||
!allViewed,
|
||||
).catch(() => undefined);
|
||||
};
|
||||
|
||||
// `Github.PullRequest.Changed.MarkViewed.Toggle` on the selection (⇧⌘S, the context menu).
|
||||
const toggleRef = useRef(() => {});
|
||||
useLayoutEffect(() => {
|
||||
toggleRef.current = () => toggle(selectedFiles(roots, selection));
|
||||
});
|
||||
useEffect(() => provideGithubHandler("toggleViewed", () => toggleRef.current()), []);
|
||||
|
||||
const onContextMenu = (_node: Node | null, keys: Set<string>, e: MouseEvent) => {
|
||||
setSelection(keys);
|
||||
const chosen = selectedFiles(roots, keys);
|
||||
const first = chosen[0];
|
||||
const allViewed = chosen.every((f) => leaf(f).isRead);
|
||||
const items: MenuItem[] = [
|
||||
{ label: "Show Diff", icon: "diff", disabled: !first, run: () => first && openDiff(first) },
|
||||
{ label: "Show Diff in a New Tab or Window", disabled: !first, run: () => first && openDiff(first) },
|
||||
{
|
||||
label: "Jump to Source",
|
||||
icon: "edit-source",
|
||||
disabled: !first || !canEdit(first),
|
||||
run: () => first && jumpToSource(first),
|
||||
},
|
||||
...(hasViewedState && chosen.length
|
||||
? [
|
||||
{
|
||||
kind: "action" as const,
|
||||
action: "github.pullRequest.toggleViewed",
|
||||
label: viewedToggleText(chosen.length, allViewed),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: "View Options",
|
||||
icon: "eye",
|
||||
submenu: [
|
||||
{ kind: "header", label: "Group By" },
|
||||
{ label: "Directory", checked: grouped, run: () => setGroupByDirectory(!grouped) },
|
||||
],
|
||||
},
|
||||
{ label: "Expand All", icon: "expand-all", run: () => setExpanded(new Set(allDirs)) },
|
||||
{ label: "Collapse All", icon: "collapse-all", run: () => setExpanded(new Set()) },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "github.pullRequest.refresh", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.copyUrl", arg: number },
|
||||
];
|
||||
e.stopPropagation();
|
||||
showContextMenu(e, items);
|
||||
};
|
||||
|
||||
if (!files.length)
|
||||
return (
|
||||
<div className={styles.treeEmpty}>
|
||||
<T>Pull request does not contain any changes</T>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tree<Node>
|
||||
handleRef={tree}
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => (n.file ? undefined : n.children)}
|
||||
isExpandable={(n) => !n.file}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
selected={selection}
|
||||
onSelectionChange={(s) => setSelection(s)}
|
||||
rowClassName={() => styles.changeRow}
|
||||
renderNode={(n, state) => (
|
||||
<ChangeRow
|
||||
node={n}
|
||||
flat={!grouped}
|
||||
progress={loading || (!n.file && state.expanded) ? null : progressOf(n, leaf)}
|
||||
hasViewedState={hasViewedState}
|
||||
canEdit={!!n.file && canEdit(n.file)}
|
||||
onToggleViewed={() => n.file && toggle([n.file])}
|
||||
onEdit={() => n.file && jumpToSource(n.file)}
|
||||
/>
|
||||
)}
|
||||
onActivate={(n) => n.file && openDiff(n.file)}
|
||||
onContextMenu={onContextMenu}
|
||||
speedSearch
|
||||
aria-label={tx("Changes")}
|
||||
className={styles.tree}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeRow(props: {
|
||||
node: Node;
|
||||
flat: boolean;
|
||||
/** Shown for files and collapsed folders once loaded. */
|
||||
progress: ProgressState | null;
|
||||
hasViewedState: boolean;
|
||||
canEdit: boolean;
|
||||
onToggleViewed: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { node, flat, progress } = props;
|
||||
const tx = useTx();
|
||||
const f = node.file;
|
||||
const name = f ? (
|
||||
<>
|
||||
<Icon name={fileIconName(node.name)} size={16} className={styles.fileIcon} />
|
||||
<span className={styles.fileName} style={{ color: KIND_COLOR[changeKind(f)] }}>
|
||||
{node.name}
|
||||
</span>
|
||||
{flat && dirName(f.filename) && <span className={styles.fileDir}>{dirName(f.filename)}</span>}
|
||||
{f.previousFilename && <span className={styles.fileDir}>{tx`renamed from ${baseName(f.previousFilename)}`}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="folder" size={16} className={styles.fileIcon} />
|
||||
<span className={styles.fileName}>{node.name}</span>
|
||||
<span className={styles.fileDir}>{fileCount(node.count)}</span>
|
||||
</>
|
||||
);
|
||||
const counter = progress && progress.discussions > 0 && (
|
||||
<span className={styles.counter}>
|
||||
<Icon name={progress.isRead ? "review-comment" : "review-comment-unread"} size={16} />
|
||||
{progress.discussions}
|
||||
</span>
|
||||
);
|
||||
// The checkbox / dot slot: files of the cumulative list get the checkbox (on hover, or
|
||||
// kept once viewed); anything unread shows the dot otherwise.
|
||||
const toggleable = !!f && props.hasViewedState;
|
||||
const checkbox = toggleable && progress && (
|
||||
<span
|
||||
role="checkbox"
|
||||
aria-checked={progress.isRead}
|
||||
aria-label={tx(progress.isRead ? "Mark as Not Viewed" : "Mark as Viewed")}
|
||||
className={clsx(styles.viewedBox, progress.isRead && styles.viewedChecked)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onToggleViewed();
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{progress.isRead && <Icon name="check" size={12} />}
|
||||
</span>
|
||||
);
|
||||
const dot = progress && !progress.isRead && <Icon name="review-file-unread" size={16} />;
|
||||
return (
|
||||
<span
|
||||
className={styles.fileRow}
|
||||
title={f ? (f.previousFilename ? `${f.previousFilename} → ${f.filename}` : f.filename) : undefined}
|
||||
>
|
||||
{name}
|
||||
{progress && (
|
||||
<span className={styles.progress}>
|
||||
{props.canEdit ? (
|
||||
<>
|
||||
<span className={styles.noHover}>{counter}</span>
|
||||
<span
|
||||
className={clsx(styles.onHover, styles.editSource)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onEdit();
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="edit-source" size={16} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
counter
|
||||
)}
|
||||
{(counter || checkbox || dot) && (
|
||||
<span className={styles.readSlot}>
|
||||
{toggleable && progress.isRead ? (
|
||||
checkbox
|
||||
) : (
|
||||
<>
|
||||
{dot && <span className={toggleable ? styles.noHover : undefined}>{dot}</span>}
|
||||
{toggleable && <span className={styles.onHover}>{checkbox}</span>}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* "Changes from {n} commits" (`CodeReviewDetailsCommitsComponentFactory`): the bold label,
|
||||
* the commit dropdown link (the selected commit's hash, else "{n} commits"; disabled with
|
||||
* one commit), its popup ("All commits (n)", a separator, then every commit with its
|
||||
* author and date, filtered by the search field) and the next / previous arrows. Below the
|
||||
* row, the selected commit's info block with Show / Hide details
|
||||
* (`CodeReviewDetailsCommitInfoComponentFactory`).
|
||||
*/
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
HighlightedText,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
Popup,
|
||||
SearchField,
|
||||
fuzzyMatch,
|
||||
listNavigate,
|
||||
T,
|
||||
type PopupAnchor,
|
||||
} from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { GHPullRequestCommit } from "../api/types";
|
||||
import { GHOST_USER } from "../data/model";
|
||||
import { prettyDateTime } from "../ui/prettyDate";
|
||||
import { commitsTitle, stepCommit } from "./model";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
/** `createCommitsPopupPresenter`: headline, body, author (the ghost user when unknown) and date. */
|
||||
function presentation(c: GHPullRequestCommit) {
|
||||
const user = c.commit.author?.user ?? GHOST_USER;
|
||||
return {
|
||||
title: c.commit.messageHeadline,
|
||||
description: c.commit.messageBody,
|
||||
author: user.name || user.login,
|
||||
date: prettyDateTime(c.commit.committedDate),
|
||||
};
|
||||
}
|
||||
|
||||
export function CommitPicker({
|
||||
commits,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
commits: readonly GHPullRequestCommit[];
|
||||
/** Index of the selected commit; -1 for all. */
|
||||
selected: number;
|
||||
onSelect: (index: number) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const link = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const commit = commits[selected];
|
||||
const arrows = commits.length > 1 && selected >= 0;
|
||||
const longest = Math.max(0, ...commits.map((c) => c.commit.abbreviatedOid.length));
|
||||
const next = stepCommit(selected, commits.length, 1);
|
||||
const previous = stepCommit(selected, commits.length, -1);
|
||||
return (
|
||||
<div className={styles.commits}>
|
||||
<span className={styles.bold}>
|
||||
<T>{commitsTitle(commits.length)}</T>
|
||||
</span>
|
||||
<button
|
||||
ref={link}
|
||||
type="button"
|
||||
className={clsx(styles.link, styles.dropdownLink)}
|
||||
disabled={commits.length <= 1}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
// The link is as wide as the longest hash once one is shown, so the arrows do not move.
|
||||
style={commit ? { minWidth: `calc(${longest}ch + 20px)` } : undefined}
|
||||
>
|
||||
<span>
|
||||
{commit
|
||||
? commit.commit.abbreviatedOid
|
||||
: commits.length === 1
|
||||
? tx("1 commit")
|
||||
: tx`${commits.length} commits`}
|
||||
</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
{arrows && (
|
||||
<>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
size="small"
|
||||
title="Next Commit"
|
||||
disabled={next === null}
|
||||
onClick={() => next !== null && onSelect(next)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-up"
|
||||
size="small"
|
||||
title="Previous Commit"
|
||||
disabled={previous === null}
|
||||
onClick={() => previous !== null && onSelect(previous)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{open && (
|
||||
<CommitsPopup
|
||||
anchor={link}
|
||||
commits={commits}
|
||||
selected={selected}
|
||||
onChoose={onSelect}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Row = { kind: "all" } | { kind: "separator" } | { kind: "commit"; index: number; match: number[] | null };
|
||||
|
||||
function CommitsPopup(props: {
|
||||
anchor: PopupAnchor;
|
||||
commits: readonly GHPullRequestCommit[];
|
||||
selected: number;
|
||||
onChoose: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { commits, selected } = props;
|
||||
const tx = useTx();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const allText = tx`All commits (${commits.length})`;
|
||||
const rows = useMemo((): Row[] => {
|
||||
const q = filter.trim();
|
||||
const out: Row[] = [];
|
||||
if (!q || fuzzyMatch(allText, q)) out.push({ kind: "all" }, { kind: "separator" });
|
||||
commits.forEach((c, index) => {
|
||||
const match = q ? fuzzyMatch(c.commit.messageHeadline, q) : null;
|
||||
if (!q || match) out.push({ kind: "commit", index, match });
|
||||
});
|
||||
return out;
|
||||
}, [commits, filter, allText]);
|
||||
const isSection = (r: Row) => r.kind === "separator";
|
||||
const current = Math.min(active, rows.length - 1);
|
||||
const choose = (r: Row | undefined) => {
|
||||
if (!r || r.kind === "separator") return;
|
||||
props.onChoose(r.kind === "all" ? -1 : r.index);
|
||||
props.onClose();
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const n = listNavigate(e.key, { items: rows, active: current, isSection, wrap: true, pageSize: 7 });
|
||||
if (n !== null && e.key !== "Home" && e.key !== "End") {
|
||||
e.preventDefault();
|
||||
setActive(n);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
choose(rows[current]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement="bottom-start"
|
||||
onClose={props.onClose}
|
||||
aria-label={tx("Commits")}
|
||||
className={styles.commitsPopup}
|
||||
>
|
||||
<div className={styles.popupSearch}>
|
||||
<SearchField
|
||||
value={filter}
|
||||
onChange={(v) => {
|
||||
setFilter(v);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search commits"
|
||||
aria-label={tx("Search commits")}
|
||||
data-autofocus
|
||||
/>
|
||||
</div>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r, i) => (r.kind === "commit" ? `c:${r.index}` : `${r.kind}:${i}`)}
|
||||
isSection={isSection}
|
||||
renderSection={() => <div className={styles.popupSeparator} />}
|
||||
sectionHeight={9}
|
||||
activeIndex={current}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(r) => choose(r)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={52}
|
||||
className={styles.commitsList}
|
||||
aria-label={tx("Commits")}
|
||||
renderItem={(r, state) => {
|
||||
if (r.kind !== "commit")
|
||||
return (
|
||||
<div className={clsx(styles.commitRow, styles.allCommitsRow)} data-active={state.active || undefined}>
|
||||
<span className={styles.check}>{selected < 0 && <Icon name="check" size={16} />}</span>
|
||||
<span>{allText}</span>
|
||||
</div>
|
||||
);
|
||||
const p = presentation(commits[r.index]!);
|
||||
return (
|
||||
<div className={styles.commitRow} data-active={state.active || undefined}>
|
||||
<span className={styles.check}>{selected === r.index && <Icon name="check" size={16} />}</span>
|
||||
<span className={styles.commitText}>
|
||||
<HighlightedText className={styles.ellipsis} text={p.title} indices={r.match} />
|
||||
<span className={clsx(styles.secondary, styles.ellipsis)}>
|
||||
{p.author}, {p.date}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
/** The selected commit: its headline (one line), Show / Hide details, then its body and "author, date". */
|
||||
export function CommitInfo({ commit }: { commit: GHPullRequestCommit }) {
|
||||
const [detailed, setDetailed] = useState(false);
|
||||
const p = presentation(commit);
|
||||
return (
|
||||
<div className={styles.commitInfo}>
|
||||
<div className={styles.commitInfoTop}>
|
||||
<span className={clsx(styles.commitInfoTitle, !detailed && styles.ellipsis)} title={p.title}>
|
||||
{p.title}
|
||||
</span>
|
||||
<button type="button" className={styles.link} onClick={() => setDetailed(!detailed)}>
|
||||
<T>{detailed ? "Hide details" : "Show details"}</T>
|
||||
</button>
|
||||
</div>
|
||||
{detailed && (
|
||||
<>
|
||||
{p.description && <div className={styles.commitDescription}>{p.description}</div>}
|
||||
<div className={styles.secondary}>
|
||||
{p.author}, {p.date}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* The details tab, with IntelliJ's new-UI spacing (`ReviewDetailsUIUtil`): 16px left and
|
||||
* 14px right gaps; title 16/16/16/14, commits row 0/16/4/14, commit info 0/16/12/14,
|
||||
* statuses 6/16/10/14 (149px at most), actions 0/14/16/14.
|
||||
*/
|
||||
.panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.centered {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
color: var(--fg-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.flex {
|
||||
flex: 1;
|
||||
}
|
||||
.ellipsis {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ActionLink */
|
||||
.link {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover:not(:disabled) {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.link:disabled {
|
||||
color: var(--fg-faint);
|
||||
cursor: default;
|
||||
}
|
||||
.dropdownLink {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.titlePanel {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px 14px 16px 16px;
|
||||
}
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
font-size: calc(var(--fs-ui) + 5px);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.number {
|
||||
color: var(--fg-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.number:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tag {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Commits and branch */
|
||||
.commitsAndBranches {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0 14px 4px 16px;
|
||||
}
|
||||
.commits {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 0 5px;
|
||||
}
|
||||
.branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 6px 0 5px;
|
||||
}
|
||||
.branchIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.branchLink {
|
||||
min-width: 0;
|
||||
}
|
||||
.branchPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.popupRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin: 0 6px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-selection);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.popupRow[data-active] {
|
||||
background: var(--bg-list-selected);
|
||||
}
|
||||
/* JBUI.CurrentTheme.Advertiser */
|
||||
.advertiser {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-popup-advertiser);
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Commits popup (CommitRenderer) */
|
||||
.commitsPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 360px;
|
||||
height: 340px;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
.popupSearch {
|
||||
flex: none;
|
||||
padding: 6px 8px 4px;
|
||||
}
|
||||
.commitsList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.popupSeparator {
|
||||
height: 1px;
|
||||
margin: 4px 8px;
|
||||
background: var(--border);
|
||||
}
|
||||
.commitRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
margin: 0 6px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-selection);
|
||||
overflow: hidden;
|
||||
}
|
||||
.commitRow[data-active] {
|
||||
background: var(--bg-list-selected);
|
||||
}
|
||||
.allCommitsRow {
|
||||
height: 26px;
|
||||
}
|
||||
.check {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
align-self: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.allCommitsRow .check {
|
||||
align-self: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
.commitText {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Commit info */
|
||||
.commitInfo {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 14px 12px 16px;
|
||||
}
|
||||
.commitInfoTop {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.commitInfoTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.commitDescription {
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Changes tree (CodeReviewProgressRenderer: 4px text gap, 10px between counter and dot, 16px right) */
|
||||
.changes {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
min-height: 60px;
|
||||
}
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.treeEmpty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.fileRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fileIcon {
|
||||
flex: none;
|
||||
}
|
||||
.fileName {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fileDir {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--fg-subtle);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.progress {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
padding: 0 8px 0 4px;
|
||||
}
|
||||
.counter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.readSlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
}
|
||||
.viewedBox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--border-checkbox);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-checkbox);
|
||||
cursor: pointer;
|
||||
}
|
||||
.viewedChecked {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
}
|
||||
.editSource {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.onHover {
|
||||
display: none;
|
||||
}
|
||||
.changeRow:hover .onHover {
|
||||
display: inline-flex;
|
||||
}
|
||||
.changeRow:hover .noHover {
|
||||
display: none;
|
||||
}
|
||||
.noHover {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Status section */
|
||||
.status {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
max-height: 149px;
|
||||
margin: 6px 14px 10px 16px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 5px 0;
|
||||
}
|
||||
.statusRow > .link {
|
||||
align-self: center;
|
||||
}
|
||||
.reviewerRow {
|
||||
padding: 3px 0;
|
||||
}
|
||||
.statusIcon {
|
||||
flex: none;
|
||||
}
|
||||
.statusText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The status label's icon-text gap is 4px. */
|
||||
.statusRow > .statusIcon + .statusText {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
/* Actions (buttons 10px apart) */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px 16px 14px;
|
||||
}
|
||||
.more {
|
||||
flex: none;
|
||||
}
|
||||
.progressText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
/* JBOptionButton: the main action and an arrow opening the others. */
|
||||
.optionButton {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
.optionMain {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.optionArrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: var(--h-button);
|
||||
margin-left: -1px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-button);
|
||||
border-top-right-radius: var(--radius-control);
|
||||
border-bottom-right-radius: var(--radius-control);
|
||||
background: var(--bg-button);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.optionArrow:hover {
|
||||
background: var(--bg-control-hover);
|
||||
}
|
||||
|
||||
/* Submit Review popup (insets 12) */
|
||||
.submitPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: var(--submit-width);
|
||||
min-width: 320px;
|
||||
height: var(--submit-height);
|
||||
min-height: 160px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
.submitHeader {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.submitEditor {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
.submitEditor:focus {
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
.submitError {
|
||||
flex: none;
|
||||
color: var(--danger);
|
||||
}
|
||||
.submitButtons {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The `#N` tab of the Pull Requests tool window (IntelliJ's `GHPRDetailsComponentFactory`),
|
||||
* top to bottom: the title with `#N` and the state tag, "View Timeline", the commit picker
|
||||
* with the head branch widget on the right, the selected commit's info, the changes tree,
|
||||
* the status section and the review flow buttons. Right-click elsewhere:
|
||||
* `Github.PullRequest.Details.Popup` (Refresh, View in Browser, Copy URL).
|
||||
*
|
||||
* Provides the `submitReview` and `toggleViewed` handlers while it is shown.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react";
|
||||
import { useVcs } from "@/features/vcs/store";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Spinner, T, showContextMenu, tooltipAttrs, type MenuItem } from "@/ui";
|
||||
import { hasPermission, reviewersStates } from "../data/model";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import { reloadPullRequestData, usePullRequestData } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { provideGithubHandler } from "../toolwindow/handlers";
|
||||
import { openTimeline } from "../toolwindow/timelineInput";
|
||||
import { StateTag } from "../ui/chips";
|
||||
import { BranchWidget } from "./BranchWidget";
|
||||
import { ChangesTree } from "./ChangesTree";
|
||||
import { CommitInfo, CommitPicker } from "./CommitPicker";
|
||||
import { isInCurrentHistory, resolveConflictsLocally, useFlowBusy } from "./flow";
|
||||
import { isHeadCheckedOut, overallReviewState, remoteOf, resolveConflicts, reviewRequestState } from "./model";
|
||||
import { ReviewActions } from "./ReviewActions";
|
||||
import { useCommitFilesOf } from "./state";
|
||||
import { StatusSection } from "./StatusSection";
|
||||
import { showSubmitReview } from "./SubmitReviewPopup";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
const PANEL_MENU: (n: number) => MenuItem[] = (n) => [
|
||||
{ kind: "action", action: "github.pullRequest.refresh", arg: n },
|
||||
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: n },
|
||||
{ kind: "action", action: "github.pullRequest.copyUrl", arg: n },
|
||||
];
|
||||
|
||||
export function DetailsPanel({ number }: { number: number }) {
|
||||
const details = usePullRequestData(number, "details");
|
||||
const submitRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
provideGithubHandler("submitReview", (n) => void showSubmitReview(n, n === number ? submitRef.current : null)),
|
||||
[number],
|
||||
);
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e, PANEL_MENU(number));
|
||||
};
|
||||
|
||||
const pr = details.value;
|
||||
return (
|
||||
<div className={styles.panel} onContextMenu={onContextMenu}>
|
||||
{pr ? (
|
||||
<Details number={number} submitRef={submitRef} />
|
||||
) : details.error ? (
|
||||
<div className={styles.centered}>
|
||||
<div className={styles.error}>{details.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => void reloadPullRequestData(number, "details").catch(() => undefined)}
|
||||
>
|
||||
<T>Retry</T>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.centered}>
|
||||
<Spinner size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Details({ number, submitRef }: { number: number; submitRef: RefObject<HTMLButtonElement | null> }) {
|
||||
const tx = useTx();
|
||||
const pr = usePullRequestData(number, "details").value!;
|
||||
const mergeability = usePullRequestData(number, "mergeability").value;
|
||||
const commits = usePullRequestData(number, "commits").value ?? [];
|
||||
const changes = usePullRequestData(number, "changes");
|
||||
const viewedState = usePullRequestData(number, "viewedState").value;
|
||||
const threads = usePullRequestData(number, "threads").value;
|
||||
const permission = useGhRepository((s) => s.connection?.repository.viewerPermission);
|
||||
const remotes = useGhRepository((s) => s.remotes);
|
||||
const branch = useVcs((s) => s.branch);
|
||||
const busy = useFlowBusy((s) => s.busy[number] !== undefined);
|
||||
|
||||
const [selected, setSelected] = useState(-1);
|
||||
// A single-commit pull request only has the cumulative list (`GHPRChangesViewModel`).
|
||||
const commit = commits.length > 1 ? commits[selected] : undefined;
|
||||
const commitFiles = useCommitFilesOf(commit?.commit.oid ?? null);
|
||||
const files = commit ? commitFiles : changes;
|
||||
|
||||
const reviewers = useMemo(() => reviewersStates(pr), [pr]);
|
||||
const reviewState = overallReviewState(pr, reviewers, mergeability?.requiredApprovingReviewsCount ?? 0);
|
||||
const checkedOut = isHeadCheckedOut(branch, remotes, pr);
|
||||
const headRemote = remoteOf(remotes, pr.headRepository?.nameWithOwner)?.remote ?? null;
|
||||
|
||||
// "Already resolved locally": the head branch is checked out and already has the base commit.
|
||||
const historyKey = checkedOut ? `${pr.baseRefOid}:${branch?.headCommit ?? ""}` : null;
|
||||
const [inHistory, setInHistory] = useState<{ key: string; value: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (historyKey)
|
||||
void isInCurrentHistory(historyKey.slice(0, historyKey.indexOf(":"))).then(
|
||||
(value) => alive && setInHistory({ key: historyKey, value }),
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [historyKey]);
|
||||
const baseInHistory = !!historyKey && inHistory?.key === historyKey && inHistory.value;
|
||||
const resolve = resolveConflicts({
|
||||
details: pr,
|
||||
remotes,
|
||||
mergeInProgress: !!branch?.operation,
|
||||
alreadyResolvedLocally: checkedOut && baseInHistory,
|
||||
});
|
||||
|
||||
const state = reviewRequestState(pr);
|
||||
return (
|
||||
<>
|
||||
<div className={styles.titlePanel}>
|
||||
<div className={styles.titleRow}>
|
||||
<h2 className={styles.title}>
|
||||
{pr.title}
|
||||
{" "}
|
||||
<a
|
||||
href={pr.url}
|
||||
className={styles.number}
|
||||
{...tooltipAttrs(tx("View in browser"))}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openInBrowser(pr.url);
|
||||
}}
|
||||
>
|
||||
#{pr.number}
|
||||
</a>
|
||||
</h2>
|
||||
{state !== "OPENED" && (
|
||||
<StateTag
|
||||
state={state === "DRAFT" ? "OPEN" : pr.state}
|
||||
isDraft={state === "DRAFT"}
|
||||
className={styles.tag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={styles.link} onClick={() => void openTimeline(number, { focus: true })}>
|
||||
<T>View Timeline</T>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.commitsAndBranches}>
|
||||
<CommitPicker commits={commits} selected={commit ? selected : -1} onSelect={setSelected} />
|
||||
<BranchWidget pr={pr} checkedOut={checkedOut} headRemote={headRemote} />
|
||||
</div>
|
||||
{commit && <CommitInfo key={commit.commit.oid} commit={commit} />}
|
||||
<div className={styles.changes}>
|
||||
{files.value ? (
|
||||
<ChangesTree
|
||||
key={commit?.commit.oid ?? "all"}
|
||||
number={number}
|
||||
files={files.value}
|
||||
commitSha={commit?.commit.oid ?? null}
|
||||
viewedState={viewedState}
|
||||
threads={threads}
|
||||
checkedOut={checkedOut}
|
||||
/>
|
||||
) : files.error ? (
|
||||
<div className={styles.centered}>
|
||||
<div className={styles.error}>
|
||||
<T>Can't load changes</T>
|
||||
</div>
|
||||
<div className={styles.secondary}>{files.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => void reloadPullRequestData(number, "changes").catch(() => undefined)}
|
||||
>
|
||||
<T>Retry</T>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.centered}>
|
||||
<Spinner size={14} />
|
||||
<T>Loading…</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StatusSection
|
||||
pr={pr}
|
||||
mergeability={mergeability}
|
||||
reviewers={reviewers}
|
||||
canManage={hasPermission(permission, "TRIAGE") || pr.viewerDidAuthor}
|
||||
busy={busy}
|
||||
resolve={resolve}
|
||||
permission={permission}
|
||||
onResolve={() => {
|
||||
if ("baseRemote" in resolve) void resolveConflictsLocally(number, resolve, checkedOut);
|
||||
}}
|
||||
/>
|
||||
<ReviewActions
|
||||
pr={pr}
|
||||
mergeability={mergeability}
|
||||
reviewers={reviewers}
|
||||
reviewState={reviewState}
|
||||
submitRef={submitRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* The review flow buttons (`GHPRDetailsActionsComponentFactory`): nothing once merged,
|
||||
* "Reopen Pull Request" when closed, "Post Pull Request" for a draft; an open pull request
|
||||
* gets its role's buttons — Request / Re-request Review…, Submit Review…, Set Myself as
|
||||
* Reviewer, the Merge split button — and the More (⋯) menu. Every action is disabled while
|
||||
* another one runs; managing needs triage access or authorship, merging the rules of
|
||||
* `pullRequestPermissions`.
|
||||
*/
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Button, Icon, IconButton, Spinner, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { GHPRMergeabilityState, GHPullRequest, GHPullRequestRequestedReviewer } from "../api/types";
|
||||
import * as ops from "../data/operations";
|
||||
import { isTeam, pullRequestPermissions, reviewerKey, type ReviewerWithState } from "../data/model";
|
||||
import { useGhRepository, usePotentialReviewers } from "../data/repository";
|
||||
import { usePullRequestData } from "../data/pullRequest";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import { adjustReviewers, merge, reRequestReview, runFlow, useFlowBusy } from "./flow";
|
||||
import {
|
||||
dedupe,
|
||||
MERGE_METHOD_LABEL,
|
||||
mergeButtonMethods,
|
||||
openedActions,
|
||||
reviewRequestState,
|
||||
reviewRole,
|
||||
type MergeMethod,
|
||||
type ReviewState,
|
||||
} from "./model";
|
||||
import { showSubmitReview } from "./SubmitReviewPopup";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
export function ReviewActions(props: {
|
||||
pr: GHPullRequest;
|
||||
mergeability: GHPRMergeabilityState | undefined;
|
||||
reviewers: readonly ReviewerWithState[];
|
||||
reviewState: ReviewState;
|
||||
/** The Submit Review… button, for the `submitReview` handler to anchor its popup. */
|
||||
submitRef: RefObject<HTMLButtonElement | null>;
|
||||
}) {
|
||||
const { pr, mergeability, reviewers, reviewState, submitRef } = props;
|
||||
const tx = useTx();
|
||||
const n = pr.number;
|
||||
const connection = useGhRepository((s) => s.connection);
|
||||
const progress = useFlowBusy((s) => s.busy[n]);
|
||||
const busy = progress !== undefined;
|
||||
const pending = usePullRequestData(n, "pendingReview").value;
|
||||
const more = useRef<HTMLButtonElement>(null);
|
||||
const [picker, setPicker] = useState<Element | null>(null);
|
||||
if (!connection) return null;
|
||||
const me = connection.currentUser;
|
||||
const repo = connection.repository;
|
||||
const perms = pullRequestPermissions(pr, repo, mergeability ?? null);
|
||||
const canManage = perms.canManage && !busy;
|
||||
const enabled: Record<MergeMethod, boolean> = {
|
||||
merge: perms.canMerge && !busy,
|
||||
squash: perms.canSquashMerge && !busy,
|
||||
rebase: perms.canRebaseMerge && !busy,
|
||||
};
|
||||
const requested = pr.reviewRequests.nodes.flatMap((r) => (r.requestedReviewer ? [r.requestedReviewer] : []));
|
||||
const requestState = reviewRequestState(pr);
|
||||
|
||||
const progressText = progress ? (
|
||||
<span className={styles.progressText}>
|
||||
<Spinner size={12} />
|
||||
{progress}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (requestState === "MERGED") return null;
|
||||
if (requestState === "CLOSED")
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={!canManage} onClick={() => void runFlow(n, () => ops.reopenPullRequest(n))}>
|
||||
Reopen Pull Request
|
||||
</Button>
|
||||
{progressText}
|
||||
</div>
|
||||
);
|
||||
if (requestState === "DRAFT")
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={!canManage} onClick={() => void runFlow(n, () => ops.markReadyForReview(n))}>
|
||||
Post Pull Request
|
||||
</Button>
|
||||
{progressText}
|
||||
</div>
|
||||
);
|
||||
|
||||
const role = reviewRole(pr, me);
|
||||
const myState = reviewers.find((r) => !isTeam(r.reviewer) && r.reviewer.id === me.id)?.state ?? null;
|
||||
const shown = openedActions(role, reviewState, requested.length, myState);
|
||||
const comments = pending?.commentsCount ?? 0;
|
||||
const methods = mergeButtonMethods(repo);
|
||||
|
||||
const mergeItems = (): MenuItem[] =>
|
||||
(["merge", "squash", "rebase"] as const).map((m) => ({
|
||||
label: MERGE_METHOD_LABEL[m],
|
||||
disabled: !enabled[m],
|
||||
run: () => void merge(n, m),
|
||||
}));
|
||||
const showMore = () => {
|
||||
const r = more.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const items: MenuItem[] = shown.more.map((item): MenuItem => {
|
||||
if (item === "requestReview")
|
||||
return { label: "Request Review…", disabled: !canManage, run: () => setPicker(more.current) };
|
||||
if (item === "mergeGroup") return { label: "Merge Review…", submenu: mergeItems() };
|
||||
return {
|
||||
label: "Close Pull Request",
|
||||
disabled: !canManage,
|
||||
run: () => void runFlow(n, () => ops.closePullRequest(n)),
|
||||
};
|
||||
});
|
||||
showContextMenu({ x: r.left, y: r.bottom + 8 }, items);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
{shown.requestReview && (
|
||||
<Button disabled={!canManage} onClick={(e) => setPicker(e.currentTarget)}>
|
||||
Request Review…
|
||||
</Button>
|
||||
)}
|
||||
{shown.reRequestReview && (
|
||||
<Button disabled={!canManage} onClick={() => void reRequestReview(n, requested, reviewers)}>
|
||||
Re-request Review…
|
||||
</Button>
|
||||
)}
|
||||
{shown.submitReview && (
|
||||
<Button ref={submitRef} onClick={(e) => void showSubmitReview(n, e.currentTarget)}>
|
||||
{comments > 0 ? tx`Submit Review (${comments})…` : tx("Submit Review…")}
|
||||
</Button>
|
||||
)}
|
||||
{shown.setMyselfAsReviewer && (
|
||||
<Button disabled={!canManage} onClick={() => void adjustReviewers(n, requested, [...requested, me])}>
|
||||
Set Myself as Reviewer
|
||||
</Button>
|
||||
)}
|
||||
{shown.merge && (
|
||||
<span className={styles.optionButton}>
|
||||
<Button
|
||||
className={styles.optionMain}
|
||||
disabled={!enabled[methods.main]}
|
||||
onClick={() => void merge(n, methods.main)}
|
||||
>
|
||||
{MERGE_METHOD_LABEL[methods.main]}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optionArrow}
|
||||
aria-label={tx("More merge options")}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.parentElement!.getBoundingClientRect();
|
||||
showContextMenu(
|
||||
{ x: r.left, y: r.bottom + 2 },
|
||||
methods.others.map((m) => ({
|
||||
label: MERGE_METHOD_LABEL[m],
|
||||
disabled: !enabled[m],
|
||||
run: () => void merge(n, m),
|
||||
})),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<IconButton ref={more} icon="more-horizontal" title="More Actions" className={styles.more} onClick={showMore} />
|
||||
{progressText}
|
||||
{picker && (
|
||||
<ReviewerPicker
|
||||
anchor={picker}
|
||||
pr={pr}
|
||||
current={dedupe([...requested, ...reviewers.map((r) => r.reviewer)])}
|
||||
onClose={(chosen) => {
|
||||
setPicker(null);
|
||||
void adjustReviewers(n, dedupe([...requested, ...reviewers.map((r) => r.reviewer)]), chosen);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `showMultipleChooserPopupWithIncrementalLoading` of the potential reviewers (the author excluded), above the button. */
|
||||
function ReviewerPicker(props: {
|
||||
anchor: Element;
|
||||
pr: GHPullRequest;
|
||||
current: GHPullRequestRequestedReviewer[];
|
||||
onClose: (chosen: GHPullRequestRequestedReviewer[]) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const candidates = usePotentialReviewers();
|
||||
const authorId = props.pr.author?.id;
|
||||
const items = candidates.items.filter((r) => isTeam(r) || r.id !== authorId);
|
||||
return (
|
||||
<ChooserPopup
|
||||
mode="multi"
|
||||
anchor={props.anchor}
|
||||
placement="top-start"
|
||||
items={items}
|
||||
loading={candidates.loading}
|
||||
error={candidates.error}
|
||||
chosen={props.current}
|
||||
getKey={reviewerKey}
|
||||
present={(r) =>
|
||||
isTeam(r)
|
||||
? { text: r.combinedSlug, detail: r.teamName, avatarUrl: r.teamAvatarUrl }
|
||||
: { text: r.login, detail: r.name, avatarUrl: r.avatarUrl }
|
||||
}
|
||||
onClose={props.onClose}
|
||||
aria-label={tx("Reviewers")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The status section (`GHPRStatusChecksComponentFactory`): "Loading pull request status"
|
||||
* until the mergeability loads, then the access line, the CI line with its "Details…"
|
||||
* chooser, the conflicts line with Resolve, the required reviews and restriction lines,
|
||||
* "Missing reviewer" and a row per reviewer (right-click: Re-request / Remove for users
|
||||
* who can manage the pull request). At most 149px high, scrolling.
|
||||
*/
|
||||
import { useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Icon, showContextMenu, T, tooltipAttrs, type MenuItem } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import type { CodeReviewCIJob, GHPRMergeabilityState, GHPullRequest } from "../api/types";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import { reviewerKey, type ReviewerWithState } from "../data/model";
|
||||
import { Avatar, AVATAR_SIZE, reviewerRingColor } from "../ui/Avatar";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import { adjustReviewers } from "./flow";
|
||||
import {
|
||||
accessDeniedText,
|
||||
ciSummary,
|
||||
isAwaitingReview,
|
||||
JOB_ICON,
|
||||
presentableName,
|
||||
reviewerStatusIcon,
|
||||
reviewerStatusText,
|
||||
STATUS_ICON,
|
||||
type ResolveConflicts,
|
||||
type StatusIcon,
|
||||
} from "./model";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
const StatusGlyph = ({ icon }: { icon: StatusIcon }) => (
|
||||
<Icon name={STATUS_ICON[icon]} size={16} className={styles.statusIcon} />
|
||||
);
|
||||
|
||||
function StatusLine({ icon, children }: { icon: StatusIcon; children: ReactNode }) {
|
||||
return (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon={icon} />
|
||||
<span className={styles.statusText}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusSection(props: {
|
||||
pr: GHPullRequest;
|
||||
mergeability: GHPRMergeabilityState | undefined;
|
||||
reviewers: readonly ReviewerWithState[];
|
||||
canManage: boolean;
|
||||
busy: boolean;
|
||||
resolve: ResolveConflicts;
|
||||
onResolve: () => void;
|
||||
permission: Parameters<typeof accessDeniedText>[0];
|
||||
}) {
|
||||
const { pr, mergeability, reviewers } = props;
|
||||
const tx = useTx();
|
||||
const access = accessDeniedText(props.permission, pr.viewerDidAuthor, pr.isDraft);
|
||||
const accessLine = access && (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon="error" />
|
||||
<span className={styles.statusText}>
|
||||
<T>{access}</T>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (!mergeability)
|
||||
return (
|
||||
<div className={styles.status}>
|
||||
<StatusLine icon="pending">
|
||||
<T>Loading pull request status</T>
|
||||
</StatusLine>
|
||||
{accessLine}
|
||||
</div>
|
||||
);
|
||||
const required = mergeability.requiredApprovingReviewsCount;
|
||||
return (
|
||||
<div className={styles.status}>
|
||||
{accessLine}
|
||||
<CiLine jobs={mergeability.ciJobs} />
|
||||
<ConflictsLine
|
||||
hasConflicts={mergeability.hasConflicts}
|
||||
resolve={props.resolve}
|
||||
busy={props.busy}
|
||||
onResolve={props.onResolve}
|
||||
/>
|
||||
{required > 0 && !pr.isDraft && (
|
||||
<StatusLine icon="failed">
|
||||
{tx`At least ${required} approving review is required by reviewers with write access`}
|
||||
</StatusLine>
|
||||
)}
|
||||
{mergeability.isRestricted && !pr.isDraft && (
|
||||
<StatusLine icon="failed">
|
||||
<T>You are not authorized to merge request into this branch</T>
|
||||
</StatusLine>
|
||||
)}
|
||||
{!reviewers.length && (
|
||||
<StatusLine icon="warning">
|
||||
<T>Missing reviewer</T>
|
||||
</StatusLine>
|
||||
)}
|
||||
{reviewers.map((r) => (
|
||||
<ReviewerRow key={reviewerKey(r.reviewer)} pr={pr} entry={r} canManage={props.canManage} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CiLine({ jobs }: { jobs: readonly CodeReviewCIJob[] }) {
|
||||
const tx = useTx();
|
||||
const link = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = ciSummary(jobs);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon={summary.icon} />
|
||||
<span className={styles.statusText}>
|
||||
<T>{summary.text}</T>
|
||||
</span>
|
||||
<button ref={link} type="button" className={styles.link} onClick={() => setOpen(true)}>
|
||||
<T>Details…</T>
|
||||
</button>
|
||||
{open && (
|
||||
<ChooserPopup
|
||||
mode="single"
|
||||
anchor={link}
|
||||
placement="top-start"
|
||||
items={jobs}
|
||||
getKey={(j) => `${j.name}\u0000${j.detailsUrl ?? ""}`}
|
||||
present={(j) => ({ text: j.name, icon: STATUS_ICON[JOB_ICON[j.status]] })}
|
||||
onChoose={(j) => j.detailsUrl && void openInBrowser(j.detailsUrl)}
|
||||
onClose={() => setOpen(false)}
|
||||
height={Math.min(300, 40 + jobs.length * 28)}
|
||||
aria-label={tx("Open in Browser")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictsLine(props: {
|
||||
hasConflicts: boolean | null;
|
||||
resolve: ResolveConflicts;
|
||||
busy: boolean;
|
||||
onResolve: () => void;
|
||||
}) {
|
||||
const { hasConflicts, resolve } = props;
|
||||
// Shown while GitHub is still checking, hidden once it found none.
|
||||
if (hasConflicts === false) return null;
|
||||
const error = "error" in resolve ? resolve.error : null;
|
||||
return (
|
||||
<div className={styles.statusRow}>
|
||||
<StatusGlyph icon={hasConflicts === null ? "pending" : "failed"} />
|
||||
<span className={styles.statusText}>
|
||||
<T>{hasConflicts === null ? "Checking branch for conflicts" : "Branch has conflicts that must be resolved"}</T>
|
||||
</span>
|
||||
{hasConflicts && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
disabled={props.busy || !!error}
|
||||
onClick={props.onResolve}
|
||||
{...(error ? tooltipAttrs(error) : {})}
|
||||
>
|
||||
<T>Resolve</T>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewerRow({ pr, entry, canManage }: { pr: GHPullRequest; entry: ReviewerWithState; canManage: boolean }) {
|
||||
const { reviewer, state } = entry;
|
||||
const tx = useTx();
|
||||
const number = pr.number;
|
||||
const name = presentableName(reviewer);
|
||||
const requested = pr.reviewRequests.nodes.flatMap((r) => (r.requestedReviewer ? [r.requestedReviewer] : []));
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
if (!canManage) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items: MenuItem[] = [
|
||||
...(!isAwaitingReview(state)
|
||||
? [
|
||||
{
|
||||
label: tx`Re-request Review from ${name}`,
|
||||
verbatim: true,
|
||||
run: () => void adjustReviewers(number, requested, [...requested, reviewer]),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: tx`Remove ${name} from reviewers`,
|
||||
verbatim: true,
|
||||
run: () =>
|
||||
void adjustReviewers(
|
||||
number,
|
||||
requested,
|
||||
requested.filter((r) => reviewerKey(r) !== reviewerKey(reviewer)),
|
||||
),
|
||||
},
|
||||
];
|
||||
showContextMenu(e, items);
|
||||
};
|
||||
const avatarUrl = "teamAvatarUrl" in reviewer ? reviewer.teamAvatarUrl : reviewer.avatarUrl;
|
||||
return (
|
||||
<div className={clsx(styles.statusRow, styles.reviewerRow)} onContextMenu={onContextMenu}>
|
||||
<Icon name={reviewerStatusIcon(state)} size={16} className={styles.statusIcon} />
|
||||
<Avatar url={avatarUrl} name={name} size={AVATAR_SIZE.OUTLINED} ring={reviewerRingColor(state)} title={null} />
|
||||
<span className={styles.statusText}>{reviewerStatusText(state, name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The Submit Review popup (`GHPRSubmitReviewPopup`, `CodeReviewSubmitPopupHandler`): bold
|
||||
* "Submit Review" with the pending comment count, the discard (pending review with
|
||||
* comments) and close buttons, the review comment editor, the error, then Approve /
|
||||
* Request Changes (not for the author) and Comment. Shown above the button that opened
|
||||
* it, or centred in the window. 500×200, resizable.
|
||||
*/
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { Button, IconButton, Popup, Spinner, T } from "@/ui";
|
||||
import { useTx, tx } from "@/platform/i18n";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { errorMessage } from "../api/client";
|
||||
import type { GHPullRequestReviewEvent } from "../api/types";
|
||||
import { deleteReview, submitOrCreateReview } from "../data/operations";
|
||||
import { loadPullRequestData, usePullRequestData } from "../data/pullRequest";
|
||||
import { openDialog } from "../ui/dialogs";
|
||||
import { setReviewText, useReviewText } from "./state";
|
||||
import styles from "./Details.module.css";
|
||||
|
||||
/** Opens the popup for pull request `number`; resolves when it closes. */
|
||||
export function showSubmitReview(number: number, anchor?: Element | null): Promise<unknown> {
|
||||
// The details and the pending review are needed for the buttons and the header.
|
||||
void loadPullRequestData(number, "details").catch(() => undefined);
|
||||
void loadPullRequestData(number, "pendingReview").catch(() => undefined);
|
||||
const at = anchor?.isConnected
|
||||
? anchor
|
||||
: { x: Math.round(window.innerWidth / 2) - 250, y: Math.round(window.innerHeight / 3) };
|
||||
return openDialog<true>((done) => <SubmitReview number={number} anchor={at} onClose={() => done(null)} />);
|
||||
}
|
||||
|
||||
function SubmitReview(props: { number: number; anchor: Element | { x: number; y: number }; onClose: () => void }) {
|
||||
const { number } = props;
|
||||
const t = useTx();
|
||||
const details = usePullRequestData(number, "details").value;
|
||||
const pending = usePullRequestData(number, "pendingReview").value;
|
||||
const text = useReviewText((s) => s.text[number] ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const comments = pending?.commentsCount ?? 0;
|
||||
const hasAnyComment = text.length > 0 || comments > 0;
|
||||
const isAuthor = details?.viewerDidAuthor ?? false;
|
||||
|
||||
const submit = async (event: GHPullRequestReviewEvent) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submitOrCreateReview(number, event, text);
|
||||
setReviewText(number, "");
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const discard = async () => {
|
||||
if (!pending) return;
|
||||
const r = await ask({
|
||||
title: tx("Discard Pending Review"),
|
||||
body: tx("Are you sure you want to delete all pending comments?"),
|
||||
buttons: [
|
||||
{ id: "yes", label: "Yes", primary: true },
|
||||
{ id: "no", label: "No" },
|
||||
],
|
||||
cancel: false,
|
||||
});
|
||||
if (r.button !== "yes") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteReview(number, pending.id);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const anchored = "getBoundingClientRect" in props.anchor;
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement={anchored ? "top-start" : "bottom-start"}
|
||||
onClose={(reason) => {
|
||||
// A click outside does not drop a review while it is being sent.
|
||||
if (reason !== "outside" || !busy) props.onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label={t("Submit Review")}
|
||||
className={styles.submitPopup}
|
||||
style={{ "--submit-width": "500px", "--submit-height": "200px" } as CSSProperties}
|
||||
>
|
||||
<div className={styles.submitHeader}>
|
||||
<span className={styles.bold}>
|
||||
<T>Submit Review</T>
|
||||
</span>
|
||||
{comments > 0 && (
|
||||
<span>{comments === 1 ? t`${comments} pending comment` : t`${comments} pending comments`}</span>
|
||||
)}
|
||||
<span className={styles.flex} />
|
||||
{pending && comments > 0 && (
|
||||
<IconButton icon="trash" size="small" title="Discard pending comments" disabled={busy} onClick={discard} />
|
||||
)}
|
||||
<IconButton icon="close" size="small" title="Close" onClick={props.onClose} />
|
||||
</div>
|
||||
<textarea
|
||||
data-autofocus
|
||||
className={styles.submitEditor}
|
||||
placeholder={t("Review comment")}
|
||||
aria-label={t("Review comment")}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setError(null);
|
||||
setReviewText(number, e.target.value);
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<div className={styles.submitError}>
|
||||
<T>Failed to submit review</T>
|
||||
<div className={styles.secondary}>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.submitButtons}>
|
||||
{!isAuthor && (
|
||||
<>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit("APPROVE")}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button disabled={busy || !hasAnyComment} onClick={() => void submit("REQUEST_CHANGES")}>
|
||||
Request Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
disabled={busy || !hasAnyComment}
|
||||
title="Submit general feedback without explicit approval"
|
||||
onClick={() => void submit("COMMENT")}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
{busy && <Spinner size={14} />}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import type { GHPRMergeabilityState, GHPullRequest, GHRepository, GHUser } from "../api/types";
|
||||
import { reviewersStates } from "../data/model";
|
||||
import { clearPullRequests, setPullRequestData } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { ReviewActions } from "./ReviewActions";
|
||||
import { StatusSection } from "./StatusSection";
|
||||
|
||||
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
|
||||
const bob: GHUser = { __typename: "User", id: "U2", login: "bob", url: "", avatarUrl: "", name: "Bob" };
|
||||
const repository: GHRepository = {
|
||||
id: "R1",
|
||||
owner: { __typename: "User", id: "O1", login: "org" },
|
||||
nameWithOwner: "org/app",
|
||||
viewerPermission: "WRITE",
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
defaultBranchRef: { name: "main" },
|
||||
isFork: false,
|
||||
url: "https://github.com/org/app",
|
||||
sshUrl: "",
|
||||
};
|
||||
const mergeability: GHPRMergeabilityState = {
|
||||
hasConflicts: true,
|
||||
ciJobs: [{ name: "build", status: "PENDING", isRequired: true, detailsUrl: null }],
|
||||
canBeMerged: true,
|
||||
canBeRebased: true,
|
||||
isRestricted: false,
|
||||
requiredApprovingReviewsCount: 1,
|
||||
checksState: null,
|
||||
mergeable: "CONFLICTING",
|
||||
mergeStateStatus: "BLOCKED",
|
||||
};
|
||||
|
||||
function pr(patch: Partial<GHPullRequest> = {}): GHPullRequest {
|
||||
return {
|
||||
id: "PR1",
|
||||
url: "https://github.com/org/app/pull/7",
|
||||
number: 7,
|
||||
title: "Title",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
author: me,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
isReadByViewer: true,
|
||||
assignees: { nodes: [] },
|
||||
labels: { nodes: [] },
|
||||
reviewRequests: { nodes: [] },
|
||||
reviewThreads: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
mergeable: "MERGEABLE",
|
||||
viewerCanUpdate: true,
|
||||
viewerCanReact: true,
|
||||
viewerDidAuthor: true,
|
||||
reactions: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [],
|
||||
},
|
||||
body: "",
|
||||
reviewDecision: null,
|
||||
baseRefName: "main",
|
||||
baseRefOid: "base",
|
||||
baseRepository: repository,
|
||||
headRef: { id: "ref" },
|
||||
headRefName: "feature",
|
||||
headRefOid: "head",
|
||||
headRepository: repository,
|
||||
viewerCanDeleteHeadRef: false,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useGhRepository.setState({
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me },
|
||||
});
|
||||
setPullRequestData(7, "pendingReview", () => null);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearPullRequests();
|
||||
useGhRepository.setState({ connection: null });
|
||||
});
|
||||
|
||||
describe("StatusSection", () => {
|
||||
it("shows the loading line until the mergeability loads", () => {
|
||||
render(
|
||||
<StatusSection
|
||||
pr={pr()}
|
||||
mergeability={undefined}
|
||||
reviewers={[]}
|
||||
canManage
|
||||
busy={false}
|
||||
resolve={{ error: "Details have not finished loading" }}
|
||||
onResolve={() => {}}
|
||||
permission="WRITE"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Loading pull request status")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists checks, conflicts, required reviews and reviewers", () => {
|
||||
const p = pr({ reviewRequests: { nodes: [{ requestedReviewer: bob }] } });
|
||||
render(
|
||||
<StatusSection
|
||||
pr={p}
|
||||
mergeability={mergeability}
|
||||
reviewers={reviewersStates(p)}
|
||||
canManage
|
||||
busy={false}
|
||||
resolve={{ error: "Merge is already in progress" }}
|
||||
onResolve={() => {}}
|
||||
permission="READ"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Repository write access required to merge pull requests")).toBeTruthy();
|
||||
expect(screen.getByText("Checks in progress…")).toBeTruthy();
|
||||
expect(screen.getByText("Details…")).toBeTruthy();
|
||||
expect(screen.getByText("Branch has conflicts that must be resolved")).toBeTruthy();
|
||||
expect((screen.getByText("Resolve").closest("button") as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText("At least 1 approving review is required by reviewers with write access")).toBeTruthy();
|
||||
expect(screen.getByText("Waiting for review from Bob")).toBeTruthy();
|
||||
expect(screen.queryByText("Missing reviewer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReviewActions", () => {
|
||||
const actions = (p: GHPullRequest, state: "ACCEPTED" | "NEED_REVIEW" = "NEED_REVIEW") =>
|
||||
render(
|
||||
<ReviewActions
|
||||
pr={p}
|
||||
mergeability={{ ...mergeability, hasConflicts: false }}
|
||||
reviewers={reviewersStates(p)}
|
||||
reviewState={state}
|
||||
submitRef={createRef()}
|
||||
/>,
|
||||
);
|
||||
|
||||
it("offers Reopen, Post and nothing for closed, draft and merged pull requests", () => {
|
||||
actions(pr({ state: "CLOSED" }));
|
||||
expect(screen.getByText("Reopen Pull Request")).toBeTruthy();
|
||||
cleanup();
|
||||
actions(pr({ isDraft: true }));
|
||||
expect(screen.getByText("Post Pull Request")).toBeTruthy();
|
||||
cleanup();
|
||||
const { container } = actions(pr({ state: "MERGED" }));
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows the author's Request Review… and, once accepted, the merge button", () => {
|
||||
actions(pr());
|
||||
expect(screen.getByText("Request Review…")).toBeTruthy();
|
||||
expect(screen.queryByText("Squash and Merge…")).toBeNull();
|
||||
cleanup();
|
||||
actions(pr(), "ACCEPTED");
|
||||
expect(screen.queryByText("Request Review…")).toBeNull();
|
||||
// Merge commits are not allowed in this repository: squash is the main action.
|
||||
expect(screen.getByText("Squash and Merge…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Submit Review… to reviewers and Set Myself as Reviewer to guests", () => {
|
||||
const asReviewer = pr({
|
||||
author: bob,
|
||||
viewerDidAuthor: false,
|
||||
reviewRequests: { nodes: [{ requestedReviewer: me }] },
|
||||
});
|
||||
actions(asReviewer);
|
||||
expect(screen.getByText("Submit Review…")).toBeTruthy();
|
||||
cleanup();
|
||||
actions(pr({ author: bob, viewerDidAuthor: false }));
|
||||
expect(screen.getByText("Set Myself as Reviewer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* The details tab's operations (`GHPRReviewFlowViewModelImpl`, `GHPRBranchesViewModel`,
|
||||
* `GHPRResolveConflictsLocallyViewModel`): one review-flow action runs at a time per pull
|
||||
* request (`isBusy` disables the others), mutations go through `data/operations.ts`, local
|
||||
* git through `data/local.ts` and the vcs feature. Failures are reported as notifications.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { DiffData } from "@/features/vcs/diffInput";
|
||||
import { mergeIntoCurrent } from "@/features/vcs/branchOps";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { errorMessage } from "../api/client";
|
||||
import type { GHCommitFile, GHPullRequest, GHPullRequestRequestedReviewer } from "../api/types";
|
||||
import { mergeCommitMessage, splitCommitMessage, type ReviewerWithState } from "../data/model";
|
||||
import { checkoutPullRequest, fileDiffData, prepareDiff } from "../data/local";
|
||||
import * as ops from "../data/operations";
|
||||
import { loadPullRequestData } from "../data/pullRequest";
|
||||
import { mergeCommitMessageDialog } from "../ui/dialogs";
|
||||
import { reviewersDelta, type MergeMethod } from "./model";
|
||||
|
||||
/** Per pull request: the running review-flow action's progress text (empty string: no text). */
|
||||
export const useFlowBusy = create<{ busy: Record<number, string> }>(() => ({ busy: {} }));
|
||||
|
||||
function setBusy(number: number, text: string | null) {
|
||||
useFlowBusy.setState((s) => {
|
||||
const busy = { ...s.busy };
|
||||
if (text === null) delete busy[number];
|
||||
else busy[number] = text;
|
||||
return { busy };
|
||||
});
|
||||
}
|
||||
|
||||
export const isFlowBusy = (number: number) => useFlowBusy.getState().busy[number] !== undefined;
|
||||
|
||||
function reportError(title: string, e: unknown) {
|
||||
notifications().notify({ group: "vcs", level: "error", title, body: errorMessage(e), key: "github-details" });
|
||||
}
|
||||
|
||||
/** `SingleCoroutineLauncher`: runs `action` unless another one of the pull request runs. */
|
||||
export async function runFlow(number: number, action: () => Promise<unknown>, progress = ""): Promise<void> {
|
||||
if (isFlowBusy(number)) return;
|
||||
setBusy(number, progress);
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
reportError(tx("Pull request action failed"), e);
|
||||
} finally {
|
||||
setBusy(number, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** `adjustReviewers` with IntelliJ's progress text. */
|
||||
export function adjustReviewers(
|
||||
number: number,
|
||||
current: readonly GHPullRequestRequestedReviewer[],
|
||||
chosen: readonly GHPullRequestRequestedReviewer[],
|
||||
) {
|
||||
const d = reviewersDelta(current, chosen);
|
||||
if (!d.added.length && !d.removed.length) return Promise.resolve();
|
||||
return runFlow(number, () => ops.adjustReviewers(number, d), tx("Adjusting reviewers…"));
|
||||
}
|
||||
|
||||
/** "Re-request Review…": requests everyone who reviewed again. */
|
||||
export function reRequestReview(
|
||||
number: number,
|
||||
requested: readonly GHPullRequestRequestedReviewer[],
|
||||
reviewers: readonly ReviewerWithState[],
|
||||
) {
|
||||
return adjustReviewers(number, requested, [...requested, ...reviewers.map((r) => r.reviewer)]);
|
||||
}
|
||||
|
||||
/** Merge… / Squash and Merge… (after the commit message dialog) / Rebase. */
|
||||
export function merge(number: number, method: MergeMethod) {
|
||||
return runFlow(number, async () => {
|
||||
const pr = await loadPullRequestData(number, "details");
|
||||
if (method === "rebase") return ops.mergePullRequest(number, "rebase", pr.headRefOid);
|
||||
const headlines =
|
||||
method === "squash"
|
||||
? (await loadPullRequestData(number, "commits")).map((c) => c.commit.messageHeadline)
|
||||
: undefined;
|
||||
const message = await mergeCommitMessageDialog({
|
||||
title: method === "squash" ? "Squash and Merge Review" : "Merge Review",
|
||||
message: mergeCommitMessage(pr, headlines),
|
||||
});
|
||||
if (message === null) return;
|
||||
await ops.mergePullRequest(number, method, pr.headRefOid, splitCommitMessage(message));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branches
|
||||
|
||||
/** "Show in Git Log": fetches the pull request, then shows its head in a log tab of its own. */
|
||||
export async function showInGitLog(pr: GHPullRequest, headRemote: string | null) {
|
||||
const revs = await prepareDiff(pr);
|
||||
if (!revs) return;
|
||||
const tracked = headRemote ? `${headRemote}/${pr.headRefName}` : null;
|
||||
const branches = tracked ? await call(commands.vcsBranches(null)).catch(() => []) : [];
|
||||
const ref = tracked && branches.some((b) => b.remote && b.name === tracked) ? tracked : revs.head;
|
||||
await executeAction("vcs.showLog", { branch: ref });
|
||||
}
|
||||
|
||||
/** "Copy Branch Name". */
|
||||
export async function copyBranchName(name: string) {
|
||||
await navigator.clipboard?.writeText(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Resolve": checks the head branch out (fetching what it needs) and merges the base
|
||||
* branch into it, which stops on the conflicts for the user to resolve and push.
|
||||
*/
|
||||
export function resolveConflictsLocally(
|
||||
number: number,
|
||||
target: { baseRemote: string; baseRefName: string },
|
||||
isCheckedOut: boolean,
|
||||
) {
|
||||
return runFlow(number, async () => {
|
||||
const pr = await loadPullRequestData(number, "details");
|
||||
// Fetches the base branch as well (updating `<remote>/<base>`).
|
||||
if (!(await prepareDiff(pr))) return;
|
||||
if (!isCheckedOut && !(await checkoutPullRequest(number))) return;
|
||||
await mergeIntoCurrent(`${target.baseRemote}/${target.baseRefName}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether `oid` is in the history of HEAD (`isInCurrentHistory`); false when unknown. */
|
||||
export async function isInCurrentHistory(oid: string): Promise<boolean> {
|
||||
try {
|
||||
const commits = await call(
|
||||
commands.vcsLog(null, {
|
||||
branch: null,
|
||||
path: null,
|
||||
author: null,
|
||||
text: null,
|
||||
range: `HEAD..${oid}`,
|
||||
skip: 0,
|
||||
limit: 1,
|
||||
}),
|
||||
);
|
||||
return commits.length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diffs of a single commit
|
||||
|
||||
/** Opens a file of commit `sha` (its parent ↔ it) in the diff viewer. */
|
||||
export async function openCommitFileDiff(number: number, sha: string, file: GHCommitFile) {
|
||||
const pr = await loadPullRequestData(number, "details");
|
||||
const revs = await prepareDiff(pr);
|
||||
if (!revs) return;
|
||||
const data: DiffData = fileDiffData(pr, file, { ...revs, base: `${sha}^`, head: sha, files: [] });
|
||||
const short = sha.slice(0, 8);
|
||||
data.left = data.left.type === "rev" ? { ...data.left, label: `${short}^` } : data.left;
|
||||
data.right = data.right.type === "rev" ? { ...data.right, label: short } : data.right;
|
||||
data.context = { ...data.context!, commit: sha };
|
||||
await executeAction("vcs.openDiff", data);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CodeReviewCIJob,
|
||||
GHCommitFile,
|
||||
GHPullRequest,
|
||||
GHPullRequestReviewThread,
|
||||
GHRepository,
|
||||
GHTeam,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
import {
|
||||
accessDeniedText,
|
||||
changeKind,
|
||||
changesTree,
|
||||
ciSummary,
|
||||
commitsTitle,
|
||||
headBranchLabel,
|
||||
isHeadCheckedOut,
|
||||
isViewed,
|
||||
mergeButtonMethods,
|
||||
openedActions,
|
||||
overallReviewState,
|
||||
presentableName,
|
||||
progressOf,
|
||||
resolveConflicts,
|
||||
reviewersDelta,
|
||||
reviewerStatusIcon,
|
||||
reviewerStatusText,
|
||||
reviewRequestState,
|
||||
reviewRole,
|
||||
selectedFiles,
|
||||
stepCommit,
|
||||
unresolvedThreadsByPath,
|
||||
viewedToggleText,
|
||||
} from "./model";
|
||||
|
||||
const user = (login: string, name: string | null = null): GHUser => ({
|
||||
__typename: "User",
|
||||
id: `id-${login}`,
|
||||
login,
|
||||
url: `https://github.com/${login}`,
|
||||
avatarUrl: "",
|
||||
name,
|
||||
});
|
||||
const team: GHTeam = {
|
||||
__typename: "Team",
|
||||
id: "T1",
|
||||
slug: "core",
|
||||
url: "",
|
||||
teamAvatarUrl: null,
|
||||
teamName: "Core",
|
||||
combinedSlug: "org/core",
|
||||
};
|
||||
const repo = (nameWithOwner: string, isFork = false): GHRepository => ({
|
||||
id: nameWithOwner,
|
||||
owner: { __typename: "User", id: "o", login: nameWithOwner.split("/")[0]! },
|
||||
nameWithOwner,
|
||||
viewerPermission: "WRITE",
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
defaultBranchRef: { name: "main" },
|
||||
isFork,
|
||||
url: `https://github.com/${nameWithOwner}`,
|
||||
sshUrl: "",
|
||||
});
|
||||
const file = (filename: string, status: GHCommitFile["status"] = "modified"): GHCommitFile => ({
|
||||
sha: null,
|
||||
filename,
|
||||
status,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
changes: 1,
|
||||
patch: null,
|
||||
previousFilename: null,
|
||||
});
|
||||
const thread = (path: string, isResolved = false) => ({ path, isResolved }) as GHPullRequestReviewThread;
|
||||
const remote = (owner: string, name: string, r: string): GHRemote => ({ owner, name, remote: r, url: "" });
|
||||
|
||||
function pr(patch: Partial<GHPullRequest> = {}): GHPullRequest {
|
||||
return {
|
||||
id: "PR1",
|
||||
url: "https://github.com/o/r/pull/7",
|
||||
number: 7,
|
||||
title: "Title",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
author: user("alice"),
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
isReadByViewer: true,
|
||||
assignees: { nodes: [] },
|
||||
labels: { nodes: [] },
|
||||
reviewRequests: { nodes: [] },
|
||||
reviewThreads: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
mergeable: "MERGEABLE",
|
||||
viewerCanUpdate: true,
|
||||
viewerCanReact: true,
|
||||
viewerDidAuthor: false,
|
||||
reactions: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: [],
|
||||
},
|
||||
body: "",
|
||||
reviewDecision: null,
|
||||
baseRefName: "main",
|
||||
baseRefOid: "base",
|
||||
baseRepository: repo("o/r"),
|
||||
headRef: { id: "ref" },
|
||||
headRefName: "feature",
|
||||
headRefOid: "head",
|
||||
headRepository: repo("o/r"),
|
||||
viewerCanDeleteHeadRef: false,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("review flow", () => {
|
||||
it("derives the request state, a draft first", () => {
|
||||
expect(reviewRequestState(pr())).toBe("OPENED");
|
||||
expect(reviewRequestState(pr({ state: "MERGED" }))).toBe("MERGED");
|
||||
expect(reviewRequestState(pr({ state: "CLOSED" }))).toBe("CLOSED");
|
||||
expect(reviewRequestState(pr({ state: "CLOSED", isDraft: true }))).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("decides the role from authorship, requests and reviews", () => {
|
||||
const bob = user("bob");
|
||||
expect(reviewRole(pr(), user("alice"))).toBe("AUTHOR");
|
||||
expect(reviewRole(pr({ reviewRequests: { nodes: [{ requestedReviewer: bob }] } }), bob)).toBe("REVIEWER");
|
||||
const reviewed = pr({ reviews: { nodes: [{ author: bob } as GHPullRequest["reviews"]["nodes"][0]] } });
|
||||
expect(reviewRole(reviewed, bob)).toBe("REVIEWER");
|
||||
expect(reviewRole(pr(), bob)).toBe("GUEST");
|
||||
});
|
||||
|
||||
it("accepts on enough approvals, else follows the review decision", () => {
|
||||
const approved = [{ reviewer: user("b"), state: "APPROVED" as const }];
|
||||
expect(overallReviewState(pr(), approved, 0)).toBe("ACCEPTED");
|
||||
expect(overallReviewState(pr(), approved, 2)).toBe("NEED_REVIEW");
|
||||
expect(overallReviewState(pr({ reviewDecision: "APPROVED" }), [], 2)).toBe("ACCEPTED");
|
||||
expect(overallReviewState(pr({ reviewDecision: "CHANGES_REQUESTED" }), [], 0)).toBe("WAIT_FOR_UPDATES");
|
||||
expect(overallReviewState(pr({ reviewDecision: "REVIEW_REQUIRED" }), [], 0)).toBe("NEED_REVIEW");
|
||||
});
|
||||
|
||||
it("shows the author's buttons per review state", () => {
|
||||
expect(openedActions("AUTHOR", "NEED_REVIEW", 0, null)).toMatchObject({
|
||||
requestReview: true,
|
||||
reRequestReview: false,
|
||||
merge: false,
|
||||
more: ["mergeGroup", "close"],
|
||||
});
|
||||
expect(openedActions("AUTHOR", "WAIT_FOR_UPDATES", 1, null)).toMatchObject({
|
||||
requestReview: true,
|
||||
reRequestReview: false,
|
||||
});
|
||||
expect(openedActions("AUTHOR", "WAIT_FOR_UPDATES", 0, null)).toMatchObject({
|
||||
requestReview: false,
|
||||
reRequestReview: true,
|
||||
});
|
||||
expect(openedActions("AUTHOR", "ACCEPTED", 0, null)).toMatchObject({
|
||||
requestReview: false,
|
||||
merge: true,
|
||||
more: ["requestReview", "close"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the reviewer's and the guest's buttons", () => {
|
||||
expect(openedActions("REVIEWER", "NEED_REVIEW", 1, "NEEDS_REVIEW")).toMatchObject({
|
||||
submitReview: true,
|
||||
merge: false,
|
||||
more: ["requestReview", "mergeGroup", "close"],
|
||||
});
|
||||
expect(openedActions("REVIEWER", "ACCEPTED", 0, "APPROVED")).toMatchObject({ submitReview: false, merge: true });
|
||||
expect(openedActions("REVIEWER", "ACCEPTED", 1, "RE_REQUESTED").merge).toBe(false);
|
||||
expect(openedActions("GUEST", "ACCEPTED", 0, null)).toMatchObject({
|
||||
setMyselfAsReviewer: true,
|
||||
merge: false,
|
||||
more: ["requestReview", "mergeGroup", "close"],
|
||||
});
|
||||
});
|
||||
|
||||
it("picks the merge button's main method from the repository settings", () => {
|
||||
expect(mergeButtonMethods({ mergeCommitAllowed: true, squashMergeAllowed: true })).toEqual({
|
||||
main: "merge",
|
||||
others: ["squash", "rebase"],
|
||||
});
|
||||
expect(mergeButtonMethods({ mergeCommitAllowed: false, squashMergeAllowed: true }).main).toBe("squash");
|
||||
expect(mergeButtonMethods({ mergeCommitAllowed: false, squashMergeAllowed: false })).toEqual({
|
||||
main: "rebase",
|
||||
others: ["merge", "squash"],
|
||||
});
|
||||
});
|
||||
|
||||
it("computes the reviewer delta by login and team slug", () => {
|
||||
const a = user("a");
|
||||
const b = user("b");
|
||||
expect(reviewersDelta([a, team], [b, { ...team }, b])).toEqual({ added: [b], removed: [a] });
|
||||
});
|
||||
|
||||
it("presents reviewers by name, else login or slug", () => {
|
||||
expect(presentableName(user("a", "Ann"))).toBe("Ann");
|
||||
expect(presentableName(user("a"))).toBe("a");
|
||||
expect(presentableName(team)).toBe("Core");
|
||||
expect(presentableName({ ...team, teamName: "" })).toBe("org/core");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status section", () => {
|
||||
const job = (status: CodeReviewCIJob["status"], isRequired = false): CodeReviewCIJob => ({
|
||||
name: status,
|
||||
status,
|
||||
isRequired,
|
||||
detailsUrl: null,
|
||||
});
|
||||
|
||||
it("summarises the CI jobs", () => {
|
||||
expect(ciSummary([])).toBeNull();
|
||||
expect(ciSummary([job("SUCCESS", true), job("FAILED")])).toEqual({
|
||||
icon: "success",
|
||||
text: "All required checks have passed",
|
||||
});
|
||||
expect(ciSummary([job("PENDING", true), job("FAILED", true)])?.icon).toBe("failedInProgress");
|
||||
expect(ciSummary([job("PENDING", true)])).toEqual({ icon: "pending", text: "Checks in progress…" });
|
||||
expect(ciSummary([job("FAILED", true), job("SKIPPED")])).toEqual({ icon: "failed", text: "Checks failed" });
|
||||
});
|
||||
|
||||
it("explains missing access", () => {
|
||||
expect(accessDeniedText("READ", false, false)).toBe("Repository access required to manage pull requests");
|
||||
expect(accessDeniedText("READ", true, false)).toBe("Repository write access required to merge pull requests");
|
||||
expect(accessDeniedText("READ", true, true)).toBeNull();
|
||||
expect(accessDeniedText("TRIAGE", false, true)).toBe("Repository write access required to merge pull requests");
|
||||
expect(accessDeniedText("WRITE", false, false)).toBeNull();
|
||||
expect(accessDeniedText("WRITE", false, false, true)).toBe("Merging is disabled for this project");
|
||||
});
|
||||
|
||||
it("words and marks reviewer states like GitHub", () => {
|
||||
expect(reviewerStatusText("APPROVED", "Ann")).toBe("Ann approved review");
|
||||
expect(reviewerStatusText("CHANGES_REQUESTED", "Ann")).toBe("Ann is waiting for updates");
|
||||
expect(reviewerStatusText("NEEDS_REVIEW", "Ann")).toBe("Waiting for review from Ann");
|
||||
expect(reviewerStatusText("RE_REQUESTED", "Ann")).toBe("Re-requested review from Ann");
|
||||
expect(reviewerStatusText("COMMENTED", "Ann")).toBe("Ann left comments");
|
||||
expect(reviewerStatusIcon("APPROVED")).toBe("success");
|
||||
expect(reviewerStatusIcon("CHANGES_REQUESTED")).toBe("error");
|
||||
expect(reviewerStatusIcon("RE_REQUESTED")).toBe("warning");
|
||||
expect(reviewerStatusIcon("COMMENTED")).toBe("review-comment");
|
||||
});
|
||||
|
||||
it("decides whether Resolve can run", () => {
|
||||
const remotes = [remote("o", "r", "origin")];
|
||||
const base = { remotes, mergeInProgress: false, alreadyResolvedLocally: false };
|
||||
expect(resolveConflicts({ ...base, details: pr() })).toEqual({
|
||||
headRemote: "origin",
|
||||
baseRemote: "origin",
|
||||
headRefName: "feature",
|
||||
baseRefName: "main",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: undefined })).toEqual({ error: "Details have not finished loading" });
|
||||
expect(resolveConflicts({ ...base, details: pr(), mergeInProgress: true })).toEqual({
|
||||
error: "Merge is already in progress",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: pr({ headRepository: null }) })).toEqual({
|
||||
error: "Source repository not found",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: pr({ headRepository: repo("fork/r", true) }) })).toEqual({
|
||||
error: "Source remote not found: https://github.com/fork/r",
|
||||
});
|
||||
expect(resolveConflicts({ ...base, details: pr(), alreadyResolvedLocally: true })).toEqual({
|
||||
error: "Already resolved locally, push your changes to resolve",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("branches", () => {
|
||||
it("shows owner:branch for forks and equal names", () => {
|
||||
expect(headBranchLabel(pr())).toBe("feature");
|
||||
expect(headBranchLabel(pr({ headRepository: repo("fork/r", true) }))).toBe("fork:feature");
|
||||
expect(headBranchLabel(pr({ headRefName: "main" }))).toBe("o:main");
|
||||
});
|
||||
|
||||
it("knows when the head branch is checked out", () => {
|
||||
const remotes = [remote("o", "r", "origin"), remote("fork", "r", "fork")];
|
||||
const head = (h: string | null, upstream: string | null, headCommit = "x") => ({ head: h, headCommit, upstream });
|
||||
expect(isHeadCheckedOut(head("feature", "origin/feature"), remotes, pr())).toBe(true);
|
||||
expect(isHeadCheckedOut(head("feature", "fork/feature"), remotes, pr())).toBe(false);
|
||||
expect(isHeadCheckedOut(head("other", "origin/other"), remotes, pr())).toBe(false);
|
||||
expect(isHeadCheckedOut(head(null, null), remotes, pr())).toBe(false);
|
||||
// gh's fork checkouts track a URL: the name and the head commit decide.
|
||||
const fork = pr({ headRepository: repo("someone/r", true) });
|
||||
expect(isHeadCheckedOut(head("someone/feature", null, "head"), remotes, fork)).toBe(true);
|
||||
expect(isHeadCheckedOut(head("feature", null, "older"), remotes, fork)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit picker", () => {
|
||||
it("labels and steps through the commits", () => {
|
||||
expect(commitsTitle(1)).toBe("Changes");
|
||||
expect(commitsTitle(3)).toBe("Changes from");
|
||||
expect(stepCommit(-1, 3, 1)).toBeNull();
|
||||
expect(stepCommit(0, 3, 1)).toBe(1);
|
||||
expect(stepCommit(2, 3, 1)).toBeNull();
|
||||
expect(stepCommit(0, 3, -1)).toBeNull();
|
||||
expect(stepCommit(2, 3, -1)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changes tree", () => {
|
||||
const files = [file("src/a.ts"), file("src/b.ts", "added"), file("README.md", "removed")];
|
||||
|
||||
it("groups by directory or lists flat", () => {
|
||||
expect(changesTree(files, true).map((n) => n.name)).toEqual(["src", "README.md"]);
|
||||
expect(changesTree(files, false).map((n) => n.name)).toEqual(["a.ts", "b.ts", "README.md"]);
|
||||
});
|
||||
|
||||
it("counts unresolved threads and the viewed state, folders summing their files", () => {
|
||||
const threads = unresolvedThreadsByPath([thread("src/a.ts"), thread("src/a.ts"), thread("src/b.ts", true)]);
|
||||
expect([...threads]).toEqual([["src/a.ts", 2]]);
|
||||
const viewed = { "src/a.ts": "VIEWED", "src/b.ts": "DISMISSED" } as const;
|
||||
const leaf = (f: GHCommitFile) => ({
|
||||
isRead: isViewed(viewed[f.filename as keyof typeof viewed]),
|
||||
discussions: threads.get(f.filename) ?? 0,
|
||||
});
|
||||
const [src, readme] = changesTree(files, true);
|
||||
expect(progressOf(src!, leaf)).toEqual({ isRead: false, discussions: 2 });
|
||||
expect(progressOf(src!.children[0]!, leaf)).toEqual({ isRead: true, discussions: 2 });
|
||||
expect(progressOf(readme!, leaf)).toEqual({ isRead: true, discussions: 0 });
|
||||
});
|
||||
|
||||
it("collects the files under the selection", () => {
|
||||
const roots = changesTree(files, true);
|
||||
expect(selectedFiles(roots, new Set(["d:src", "f:src/a.ts"])).map((f) => f.filename)).toEqual([
|
||||
"src/a.ts",
|
||||
"src/b.ts",
|
||||
]);
|
||||
expect(selectedFiles(roots, new Set(["f:README.md"])).map((f) => f.filename)).toEqual(["README.md"]);
|
||||
});
|
||||
|
||||
it("colours by VCS status and words the viewed toggle", () => {
|
||||
expect(changeKind(file("a", "added"))).toBe("added");
|
||||
expect(changeKind(file("a", "removed"))).toBe("deleted");
|
||||
expect(changeKind(file("a", "renamed"))).toBe("modified");
|
||||
expect(viewedToggleText(1, false)).toBe("Mark as Viewed");
|
||||
expect(viewedToggleText(1, true)).toBe("Mark as Not Viewed");
|
||||
expect(viewedToggleText(2, false)).toBe("Mark All as Viewed");
|
||||
expect(viewedToggleText(2, true)).toBe("Mark All as Not Viewed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Pure rules of the details tab, ported from IntelliJ: the review flow
|
||||
* (`GHPRReviewFlowViewModelImpl`, `GHPRDetailsActionsComponentFactory`), the status
|
||||
* section (`GHPRStatusChecksComponentFactory`, `CodeReviewDetailsStatusComponentFactory`,
|
||||
* `GHPRReviewerStateUIUtil`), the commit picker (`CodeReviewDetailsCommitsComponentFactory`),
|
||||
* the branch widget (`GHPRBranchesViewModel`), the changes tree's progress state
|
||||
* (`CodeReviewProgressTreeModel`, `GHPRChangeListViewModel`) and the viewed toggle
|
||||
* (`GHPRViewedStateToggleAction`).
|
||||
*/
|
||||
import type { BranchInfo } from "@/bindings";
|
||||
import { buildFileTree, type FileTreeNode } from "@/features/vcs/fileTree";
|
||||
import type { EffectiveKind } from "@/features/vcs/model";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type {
|
||||
CodeReviewCIJob,
|
||||
CodeReviewCIJobState,
|
||||
GHCommitFile,
|
||||
GHPRReviewerState,
|
||||
GHPullRequest,
|
||||
GHPullRequestFileViewedState,
|
||||
GHPullRequestRequestedReviewer,
|
||||
GHPullRequestReviewThread,
|
||||
GHRepositoryPermissionLevel,
|
||||
GHUser,
|
||||
} from "../api/types";
|
||||
import { hasPermission, isTeam, reviewerKey, type ReviewerWithState } from "../data/model";
|
||||
import type { GHRemote } from "../data/repository";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review flow
|
||||
|
||||
/** `ReviewRequestState`: a draft is DRAFT whatever its state. */
|
||||
export type ReviewRequestState = "OPENED" | "CLOSED" | "MERGED" | "DRAFT";
|
||||
/** `ReviewRole`. */
|
||||
export type ReviewRole = "AUTHOR" | "REVIEWER" | "GUEST";
|
||||
/** `ReviewState`: the overall review state. */
|
||||
export type ReviewState = "ACCEPTED" | "WAIT_FOR_UPDATES" | "NEED_REVIEW";
|
||||
|
||||
export function reviewRequestState(pr: Pick<GHPullRequest, "state" | "isDraft">): ReviewRequestState {
|
||||
if (pr.isDraft) return "DRAFT";
|
||||
if (pr.state === "CLOSED") return "CLOSED";
|
||||
if (pr.state === "MERGED") return "MERGED";
|
||||
return "OPENED";
|
||||
}
|
||||
|
||||
/** AUTHOR when `me` wrote the pull request, REVIEWER when requested or when they reviewed, else GUEST. */
|
||||
export function reviewRole(
|
||||
pr: Pick<GHPullRequest, "author" | "reviewRequests" | "reviews">,
|
||||
me: Pick<GHUser, "id">,
|
||||
): ReviewRole {
|
||||
if (pr.author?.id === me.id) return "AUTHOR";
|
||||
const requested = pr.reviewRequests.nodes.some((r) => r.requestedReviewer?.id === me.id);
|
||||
if (requested || pr.reviews.nodes.some((r) => r.author?.id === me.id)) return "REVIEWER";
|
||||
return "GUEST";
|
||||
}
|
||||
|
||||
/**
|
||||
* ACCEPTED when the approvals reach the required count (one when none is required),
|
||||
* else from GitHub's review decision.
|
||||
*/
|
||||
export function overallReviewState(
|
||||
pr: Pick<GHPullRequest, "reviewDecision">,
|
||||
reviewers: readonly ReviewerWithState[],
|
||||
requiredApprovingReviewsCount: number,
|
||||
): ReviewState {
|
||||
const approved = reviewers.filter((r) => r.state === "APPROVED").length;
|
||||
const isApproved = requiredApprovingReviewsCount === 0 ? approved > 0 : approved >= requiredApprovingReviewsCount;
|
||||
if (isApproved || pr.reviewDecision === "APPROVED") return "ACCEPTED";
|
||||
if (pr.reviewDecision === "CHANGES_REQUESTED") return "WAIT_FOR_UPDATES";
|
||||
return "NEED_REVIEW";
|
||||
}
|
||||
|
||||
/** `GHPRReviewerState.isAwaitingReview`. */
|
||||
export const isAwaitingReview = (s: GHPRReviewerState | null | undefined) =>
|
||||
s === "NEEDS_REVIEW" || s === "RE_REQUESTED";
|
||||
|
||||
/** Entries of the More (⋯) menu. */
|
||||
export type MoreItem = "requestReview" | "mergeGroup" | "close";
|
||||
|
||||
/** Which review-flow controls an open pull request shows. */
|
||||
export interface OpenedActions {
|
||||
requestReview: boolean;
|
||||
reRequestReview: boolean;
|
||||
submitReview: boolean;
|
||||
setMyselfAsReviewer: boolean;
|
||||
merge: boolean;
|
||||
more: MoreItem[];
|
||||
}
|
||||
|
||||
/** The open pull request's buttons and More menu for `role` (`GHPRDetailsActionsComponentFactory`). */
|
||||
export function openedActions(
|
||||
role: ReviewRole,
|
||||
state: ReviewState,
|
||||
requestedReviewers: number,
|
||||
myState: GHPRReviewerState | null,
|
||||
): OpenedActions {
|
||||
const none: OpenedActions = {
|
||||
requestReview: false,
|
||||
reRequestReview: false,
|
||||
submitReview: false,
|
||||
setMyselfAsReviewer: false,
|
||||
merge: false,
|
||||
more: [],
|
||||
};
|
||||
const accepted = state === "ACCEPTED";
|
||||
if (role === "AUTHOR")
|
||||
return {
|
||||
...none,
|
||||
requestReview: state === "NEED_REVIEW" || (state === "WAIT_FOR_UPDATES" && requestedReviewers > 0),
|
||||
reRequestReview: state === "WAIT_FOR_UPDATES" && requestedReviewers === 0,
|
||||
merge: accepted,
|
||||
more: accepted ? ["requestReview", "close"] : ["mergeGroup", "close"],
|
||||
};
|
||||
if (role === "REVIEWER")
|
||||
return {
|
||||
...none,
|
||||
submitReview: myState !== "APPROVED",
|
||||
merge: accepted && !isAwaitingReview(myState),
|
||||
more: accepted ? ["requestReview", "close"] : ["requestReview", "mergeGroup", "close"],
|
||||
};
|
||||
return { ...none, setMyselfAsReviewer: true, more: ["requestReview", "mergeGroup", "close"] };
|
||||
}
|
||||
|
||||
export type MergeMethod = "merge" | "squash" | "rebase";
|
||||
|
||||
/** The merge split button's main method (the first the repository allows) and the others in its menu. */
|
||||
export function mergeButtonMethods(repo: { mergeCommitAllowed: boolean; squashMergeAllowed: boolean }): {
|
||||
main: MergeMethod;
|
||||
others: MergeMethod[];
|
||||
} {
|
||||
const all: MergeMethod[] = ["merge", "squash", "rebase"];
|
||||
const main: MergeMethod = repo.mergeCommitAllowed ? "merge" : repo.squashMergeAllowed ? "squash" : "rebase";
|
||||
return { main, others: all.filter((m) => m !== main) };
|
||||
}
|
||||
|
||||
export const MERGE_METHOD_LABEL: Record<MergeMethod, string> = {
|
||||
merge: "Merge…",
|
||||
squash: "Squash and Merge…",
|
||||
rebase: "Rebase",
|
||||
};
|
||||
|
||||
/** Reviewers to add and remove to go from `current` to `chosen` (`CollectionDelta`). */
|
||||
export function reviewersDelta(
|
||||
current: readonly GHPullRequestRequestedReviewer[],
|
||||
chosen: readonly GHPullRequestRequestedReviewer[],
|
||||
): { added: GHPullRequestRequestedReviewer[]; removed: GHPullRequestRequestedReviewer[] } {
|
||||
const before = new Set(current.map(reviewerKey));
|
||||
const after = new Set(chosen.map(reviewerKey));
|
||||
return {
|
||||
added: dedupe(chosen).filter((r) => !before.has(reviewerKey(r))),
|
||||
removed: dedupe(current).filter((r) => !after.has(reviewerKey(r))),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reviewers without repeats (by login / team slug). */
|
||||
export function dedupe(reviewers: readonly GHPullRequestRequestedReviewer[]): GHPullRequestRequestedReviewer[] {
|
||||
const seen = new Set<string>();
|
||||
return reviewers.filter((r) => {
|
||||
const k = reviewerKey(r);
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** `getPresentableName`: the name, else the login (teams: name, else `org/slug`). */
|
||||
export function presentableName(r: GHPullRequestRequestedReviewer): string {
|
||||
if (isTeam(r)) return r.teamName || r.combinedSlug;
|
||||
return r.name || r.login;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status section
|
||||
|
||||
/** A status row's icon (`CIBuildStatusIcons` / `ReviewDetailsUIUtil`). */
|
||||
export type StatusIcon = "success" | "failed" | "pending" | "failedInProgress" | "skipped" | "warning" | "error";
|
||||
|
||||
export const STATUS_ICON: Record<StatusIcon, string> = {
|
||||
success: "success",
|
||||
failed: "ci-failed",
|
||||
pending: "ci-pending",
|
||||
failedInProgress: "ci-failed-in-progress",
|
||||
skipped: "ci-skipped",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export const JOB_ICON: Record<CodeReviewCIJobState, StatusIcon> = {
|
||||
FAILED: "failed",
|
||||
PENDING: "pending",
|
||||
SKIPPED: "skipped",
|
||||
SUCCESS: "success",
|
||||
};
|
||||
|
||||
/** The CI line (`calcPipelineIcon` / `calcPipelineText`); null without jobs. */
|
||||
export function ciSummary(jobs: readonly CodeReviewCIJob[]): { icon: StatusIcon; text: string } | null {
|
||||
if (!jobs.length) return null;
|
||||
const failed = jobs.filter((j) => j.status === "FAILED").length;
|
||||
const pending = jobs.filter((j) => j.status === "PENDING").length;
|
||||
if (jobs.filter((j) => j.isRequired).every((j) => j.status === "SUCCESS"))
|
||||
return { icon: "success", text: "All required checks have passed" };
|
||||
if (pending && failed) return { icon: "failedInProgress", text: "Checks in progress, and some already failed…" };
|
||||
if (pending) return { icon: "pending", text: "Checks in progress…" };
|
||||
return { icon: "failed", text: "Checks failed" };
|
||||
}
|
||||
|
||||
/** The access line (`createAccessDeniedLabel`); null when the user may do everything. */
|
||||
export function accessDeniedText(
|
||||
permission: GHRepositoryPermissionLevel | null | undefined,
|
||||
viewerDidAuthor: boolean,
|
||||
isDraft: boolean,
|
||||
mergeForbidden = false,
|
||||
): string | null {
|
||||
const canMerge = hasPermission(permission, "WRITE");
|
||||
const canClose = hasPermission(permission, "TRIAGE") || viewerDidAuthor;
|
||||
const canMarkReadyForReview = canMerge || viewerDidAuthor;
|
||||
if (!canClose) return "Repository access required to manage pull requests";
|
||||
if (!canMarkReadyForReview && isDraft) return "Repository write access required to merge pull requests";
|
||||
if (!canMerge && !isDraft) return "Repository write access required to merge pull requests";
|
||||
if (mergeForbidden && !isDraft) return "Merging is disabled for this project";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A reviewer row's text (`GHPRReviewerStateUIUtil.getText`). */
|
||||
export function reviewerStatusText(state: GHPRReviewerState, name: string): string {
|
||||
switch (state) {
|
||||
case "APPROVED":
|
||||
return tx`${name} approved review`;
|
||||
case "CHANGES_REQUESTED":
|
||||
return tx`${name} is waiting for updates`;
|
||||
case "NEEDS_REVIEW":
|
||||
return tx`Waiting for review from ${name}`;
|
||||
case "RE_REQUESTED":
|
||||
return tx`Re-requested review from ${name}`;
|
||||
case "COMMENTED":
|
||||
return tx`${name} left comments`;
|
||||
}
|
||||
}
|
||||
|
||||
/** A reviewer row's status icon: approved, changes requested, awaiting (warning), commented (neutral comment). */
|
||||
export function reviewerStatusIcon(state: GHPRReviewerState): string {
|
||||
if (state === "APPROVED") return "success";
|
||||
if (state === "CHANGES_REQUESTED") return "error";
|
||||
if (state === "COMMENTED") return "review-comment";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
/** Why Resolve cannot run (`GHPRResolveConflictsLocallyError`), or the remotes it merges between. */
|
||||
export type ResolveConflicts =
|
||||
| { error: string }
|
||||
| { headRemote: string; baseRemote: string; headRefName: string; baseRefName: string };
|
||||
|
||||
/** The local git remote of `nameWithOwner` among the project's GitHub remotes. */
|
||||
export function remoteOf(remotes: readonly GHRemote[], nameWithOwner: string | null | undefined): GHRemote | null {
|
||||
if (!nameWithOwner) return null;
|
||||
const key = nameWithOwner.toLowerCase();
|
||||
return remotes.find((r) => `${r.owner}/${r.name}`.toLowerCase() === key) ?? null;
|
||||
}
|
||||
|
||||
/** `GHPRResolveConflictsLocallyViewModel.requestOrError`. */
|
||||
export function resolveConflicts(input: {
|
||||
details: GHPullRequest | undefined;
|
||||
remotes: readonly GHRemote[];
|
||||
/** A merge or rebase is in progress in the repository. */
|
||||
mergeInProgress: boolean;
|
||||
/** The current branch tracks the head branch and contains the base commit. */
|
||||
alreadyResolvedLocally: boolean;
|
||||
}): ResolveConflicts {
|
||||
const { details: pr, remotes } = input;
|
||||
if (input.mergeInProgress) return { error: "Merge is already in progress" };
|
||||
if (!pr) return { error: "Details have not finished loading" };
|
||||
if (!pr.headRepository) return { error: tx`${tx("Source")} repository not found` };
|
||||
if (!pr.baseRepository) return { error: tx`${tx("Target")} repository not found` };
|
||||
const head = remoteOf(remotes, pr.headRepository.nameWithOwner);
|
||||
if (!head) return { error: tx`${tx("Source")} remote not found: ${pr.headRepository.url}` };
|
||||
const base = remoteOf(remotes, pr.baseRepository.nameWithOwner);
|
||||
if (!base) return { error: tx`${tx("Target")} remote not found: ${pr.baseRepository.url}` };
|
||||
if (input.alreadyResolvedLocally) return { error: "Already resolved locally, push your changes to resolve" };
|
||||
return { headRemote: head.remote, baseRemote: base.remote, headRefName: pr.headRefName, baseRefName: pr.baseRefName };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branches
|
||||
|
||||
/** The head branch as the widget shows it: `owner:branch` for forks and when head and base names are equal. */
|
||||
export function headBranchLabel(pr: Pick<GHPullRequest, "headRefName" | "baseRefName" | "headRepository">): string {
|
||||
const repo = pr.headRepository;
|
||||
if (repo && (repo.isFork || pr.baseRefName === pr.headRefName)) return `${repo.owner.login}:${pr.headRefName}`;
|
||||
return pr.headRefName;
|
||||
}
|
||||
|
||||
/** The GitHub repository of a branch's upstream (`origin/feature` or a fetch URL), with its branch name. */
|
||||
function upstreamOf(upstream: string, remotes: readonly GHRemote[]): { repo: string | null; branch: string } | null {
|
||||
const named = remotes
|
||||
.filter((r) => upstream.startsWith(`${r.remote}/`))
|
||||
.sort((a, b) => b.remote.length - a.remote.length)[0];
|
||||
if (named) return { repo: `${named.owner}/${named.name}`, branch: upstream.slice(named.remote.length + 1) };
|
||||
const slash = upstream.indexOf("/");
|
||||
return slash > 0 ? { repo: null, branch: upstream.slice(slash + 1) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pull request's head branch is checked out (`testRemoteBranchCheckedOut`): the
|
||||
* current branch tracks the head repository's branch, or (gh's fork checkouts, which track
|
||||
* a URL) it is named like the head branch and sits on the head commit.
|
||||
*/
|
||||
export function isHeadCheckedOut(
|
||||
branch: Pick<BranchInfo, "head" | "headCommit" | "upstream"> | null,
|
||||
remotes: readonly GHRemote[],
|
||||
pr: Pick<GHPullRequest, "headRefName" | "headRefOid" | "headRepository">,
|
||||
): boolean {
|
||||
if (!branch?.head) return false;
|
||||
const up = branch.upstream ? upstreamOf(branch.upstream, remotes) : null;
|
||||
if (up?.repo && pr.headRepository)
|
||||
return up.branch === pr.headRefName && up.repo.toLowerCase() === pr.headRepository.nameWithOwner.toLowerCase();
|
||||
const name = branch.head.slice(branch.head.lastIndexOf("/") + 1);
|
||||
return (name === pr.headRefName || branch.head === pr.headRefName) && branch.headCommit === pr.headRefOid;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit picker
|
||||
|
||||
/** The bold label before the dropdown: "Changes" for one commit, else "Changes from". */
|
||||
export const commitsTitle = (count: number) => (count === 1 ? "Changes" : "Changes from");
|
||||
|
||||
/** Index of the next (1) / previous (-1) commit, or null at either end or with all commits shown. */
|
||||
export function stepCommit(selected: number, count: number, dir: 1 | -1): number | null {
|
||||
if (selected < 0) return null;
|
||||
const next = selected + dir;
|
||||
return next >= 0 && next < count ? next : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Changes tree
|
||||
|
||||
/** `CodeReviewProgressTreeModel`'s node state: read (viewed) and the unresolved discussions. */
|
||||
export interface ProgressState {
|
||||
isRead: boolean;
|
||||
discussions: number;
|
||||
}
|
||||
|
||||
/** Unresolved review threads per path (`unresolvedThreadsCountByChange`). */
|
||||
export function unresolvedThreadsByPath(threads: readonly GHPullRequestReviewThread[]): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
for (const t of threads) if (!t.isResolved) out.set(t.path, (out.get(t.path) ?? 0) + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Only VIEWED counts as viewed (DISMISSED: changed since); a path without state counts as read. */
|
||||
export const isViewed = (state: GHPullRequestFileViewedState | undefined) => state === undefined || state === "VIEWED";
|
||||
|
||||
/** A tree node's progress: a file's own, a folder's the sum of its files (read when all are). */
|
||||
export function progressOf(
|
||||
node: FileTreeNode<GHCommitFile>,
|
||||
leaf: (file: GHCommitFile) => ProgressState,
|
||||
): ProgressState {
|
||||
if (node.file) return leaf(node.file);
|
||||
let isRead = true;
|
||||
let discussions = 0;
|
||||
for (const c of node.children) {
|
||||
const s = progressOf(c, leaf);
|
||||
isRead &&= s.isRead;
|
||||
discussions += s.discussions;
|
||||
}
|
||||
return { isRead, discussions };
|
||||
}
|
||||
|
||||
const fileName = (path: string) => path.slice(path.lastIndexOf("/") + 1);
|
||||
|
||||
/** The tree's roots: grouped by directory, or a flat list. */
|
||||
export function changesTree(files: readonly GHCommitFile[], groupByDirectory: boolean): FileTreeNode<GHCommitFile>[] {
|
||||
if (groupByDirectory) return buildFileTree(files, (f) => f.filename);
|
||||
return [...files]
|
||||
.sort((a, b) => fileName(a.filename).localeCompare(fileName(b.filename)) || a.filename.localeCompare(b.filename))
|
||||
.map((f) => ({ key: `f:${f.filename}`, name: fileName(f.filename), file: f, children: [], count: 1 }));
|
||||
}
|
||||
|
||||
/** Files of the selected nodes, folders contributing all theirs (`SELECTED_CHANGES`), in tree order. */
|
||||
export function selectedFiles(roots: readonly FileTreeNode<GHCommitFile>[], keys: ReadonlySet<string>): GHCommitFile[] {
|
||||
const out: GHCommitFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (n: FileTreeNode<GHCommitFile>) => {
|
||||
if (n.file) {
|
||||
if (!seen.has(n.file.filename)) out.push(n.file);
|
||||
seen.add(n.file.filename);
|
||||
} else n.children.forEach(add);
|
||||
};
|
||||
const walk = (ns: readonly FileTreeNode<GHCommitFile>[]) => {
|
||||
for (const n of ns) {
|
||||
if (keys.has(n.key)) add(n);
|
||||
else walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(roots);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The file's VCS status colour kind (`Change.getFileStatus`). */
|
||||
export function changeKind(f: Pick<GHCommitFile, "status">): EffectiveKind {
|
||||
if (f.status === "added" || f.status === "copied") return "added";
|
||||
if (f.status === "removed") return "deleted";
|
||||
return "modified";
|
||||
}
|
||||
|
||||
/** The viewed toggle's text (`GHPRViewedStateToggleAction`). */
|
||||
export function viewedToggleText(count: number, allViewed: boolean): string {
|
||||
if (count === 1) return allViewed ? "Mark as Not Viewed" : "Mark as Viewed";
|
||||
return allViewed ? "Mark All as Not Viewed" : "Mark All as Viewed";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Client state of the details tab: the changes of single commits (REST `commits/{sha}`,
|
||||
* cached per commit), the changes tree's grouping (`GithubPullRequestsProjectUISettings
|
||||
* .changesGrouping`, kept in local storage) and the Submit Review popup's text, kept per
|
||||
* pull request until the review is submitted (IntelliJ keeps it as the pending review's
|
||||
* comment).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { errorMessage } from "../api/client";
|
||||
import { commitChangedFiles } from "../api/requests";
|
||||
import type { GHCommitFile } from "../api/types";
|
||||
import type { Loadable } from "../data/pullRequest";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
|
||||
const useCommitFiles = create<{ bySha: Record<string, Loadable<GHCommitFile[]>> }>(() => ({ bySha: {} }));
|
||||
|
||||
const EMPTY: Loadable<GHCommitFile[]> = { value: undefined, loading: false, error: null };
|
||||
|
||||
function loadCommitFiles(sha: string) {
|
||||
const cur = useCommitFiles.getState().bySha[sha];
|
||||
if (cur?.value || cur?.loading) return;
|
||||
const put = (v: Loadable<GHCommitFile[]>) => useCommitFiles.setState((s) => ({ bySha: { ...s.bySha, [sha]: v } }));
|
||||
put({ value: undefined, loading: true, error: null });
|
||||
commitChangedFiles(sha).then(
|
||||
(value) => put({ value, loading: false, error: null }),
|
||||
(e: unknown) => put({ value: undefined, loading: false, error: errorMessage(e) }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The files commit `sha` changes; nothing for null. */
|
||||
export function useCommitFilesOf(sha: string | null): Loadable<GHCommitFile[]> {
|
||||
useEffect(() => {
|
||||
if (sha) loadCommitFiles(sha);
|
||||
}, [sha]);
|
||||
return useCommitFiles((s) => (sha ? (s.bySha[sha] ?? EMPTY) : EMPTY));
|
||||
}
|
||||
|
||||
/** Drops the cached commit changes (Refresh). */
|
||||
export function clearCommitFiles() {
|
||||
useCommitFiles.setState({ bySha: {} });
|
||||
}
|
||||
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
if (s.connection?.coordinates !== prev.connection?.coordinates) clearCommitFiles();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GROUPING_KEY = "openidea.github.changesGrouping.v1";
|
||||
|
||||
function loadGrouping(): boolean {
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(GROUPING_KEY) !== "none";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Group By › Directory of the changes tree (on by default). */
|
||||
export const useChangesGrouping = create<{ byDirectory: boolean }>(() => ({ byDirectory: loadGrouping() }));
|
||||
|
||||
export function setGroupByDirectory(on: boolean) {
|
||||
useChangesGrouping.setState({ byDirectory: on });
|
||||
try {
|
||||
globalThis.localStorage?.setItem(GROUPING_KEY, on ? "directory" : "none");
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The Submit Review popup's text per pull request. */
|
||||
export const useReviewText = create<{ text: Record<number, string> }>(() => ({ text: {} }));
|
||||
|
||||
export function setReviewText(number: number, text: string) {
|
||||
useReviewText.setState((s) => ({ text: { ...s.text, [number]: text } }));
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача