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

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

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

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

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

No files matched your search

+103
Переглянути файл
@@ -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>
);
}
+280
Переглянути файл
@@ -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>
);
}
+242
Переглянути файл
@@ -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>
);
}
+481
Переглянути файл
@@ -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;
}
+214
Переглянути файл
@@ -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}
/>
</>
);
}
+216
Переглянути файл
@@ -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")}
/>
);
}
+213
Переглянути файл
@@ -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>
);
}
+144
Переглянути файл
@@ -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>
);
}
+177
Переглянути файл
@@ -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();
});
});
+162
Переглянути файл
@@ -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);
}
+347
Переглянути файл
@@ -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");
});
});
+409
Переглянути файл
@@ -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";
}
+79
Переглянути файл
@@ -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 } }));
}