mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(github): pull request tool window, timeline and review flow (#477)
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication. - List with search, quick filters and paging; a tab per pull request and a New Pull Request tab. - Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen. - Timeline editor: comments, review threads, reactions and suggested changes. - Review in the diff viewer and, for the checked-out branch, in the editor (review mode). - New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands. - CSP: img-src allows https://avatars.githubusercontent.com for avatars. Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane. Co-authored-by: imacross <[email protected]>
This commit is contained in:
1 parent
696377acc2
commit
059fd8a69b
192 files changed
+23668
-2076
No files matched your search
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The row's hover balloons (`ReviewListCellRenderer.LazyIdeToolTip`): after the tooltip
|
||||
* delay, a balloon to the right of the labels icon or an avatar stack, titled with the
|
||||
* collection's name ("Labels", "Reviewers") over a list of icon + text rows. It stays
|
||||
* open while the pointer is over the trigger or the balloon.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Popup, TOOLTIP_DELAY } from "@/ui";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
export interface BalloonItem {
|
||||
key: string;
|
||||
icon: ReactNode;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const HIDE_GRACE_MS = 120;
|
||||
|
||||
export function HoverBalloon({
|
||||
title,
|
||||
items,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
items: readonly BalloonItem[];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const anchor = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
const later = (next: boolean, ms: number) => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setOpen(next), ms);
|
||||
};
|
||||
const enter = () => (open ? clearTimeout(timer.current) : later(true, TOOLTIP_DELAY));
|
||||
const leave = () => (open ? later(false, HIDE_GRACE_MS) : clearTimeout(timer.current));
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={anchor}
|
||||
className={className}
|
||||
onPointerEnter={enter}
|
||||
onPointerLeave={leave}
|
||||
onPointerDown={() => {
|
||||
clearTimeout(timer.current);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<Popup
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
placement="right"
|
||||
offset={6}
|
||||
role="tooltip"
|
||||
autoFocus={false}
|
||||
restoreFocus={false}
|
||||
onClose={() => setOpen(false)}
|
||||
className={styles.balloon}
|
||||
>
|
||||
<div onPointerEnter={enter} onPointerLeave={leave}>
|
||||
<div className={styles.balloonTitle}>{title}</div>
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className={styles.balloonRow}>
|
||||
{item.icon}
|
||||
<span>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* The pull request list (IntelliJ's `GHPRListPanelFactory`, `ReviewListCellRenderer`,
|
||||
* `ReviewListSearchPanelFactory`). Sizes are IntelliJ's at 100% scale.
|
||||
*/
|
||||
.panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Search panel: 0 / 10px (the tab adds 8px above). */
|
||||
.search {
|
||||
flex: none;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.filterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.quickFilter {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
/* BadgeIconSupplier's live indicator. */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gh-unread);
|
||||
box-shadow: 0 0 0 1.5px var(--bg-elev);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Chips: a horizontally scrolling row, 4px apart, faded 10px at the clipped edges, thin overlay scrollbar. */
|
||||
.chips {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 2px 10px 4px;
|
||||
margin-bottom: -2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.chips::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.chips::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
.chips:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
}
|
||||
.chips::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.chips[data-fade="left"] {
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 10px);
|
||||
}
|
||||
.chips[data-fade="right"] {
|
||||
mask-image: linear-gradient(to left, transparent 0, #000 10px);
|
||||
}
|
||||
.chips[data-fade="both"] {
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 10px, #000 calc(100% - 10px), transparent 100%);
|
||||
}
|
||||
|
||||
/* FilterComponent: the name and a chevron, or the value and a reset ✕. */
|
||||
.chip {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-small);
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--bg-toolbar-hover);
|
||||
}
|
||||
.chip[data-set] {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.chipMain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
max-width: 200px;
|
||||
padding: 0 4px 0 6px;
|
||||
overflow: hidden;
|
||||
font-size: var(--fs-ui);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip[data-set] .chipMain {
|
||||
padding-right: 2px;
|
||||
}
|
||||
.chipText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chipChevron,
|
||||
.chipReset {
|
||||
flex: none;
|
||||
color: var(--fg-icon);
|
||||
}
|
||||
.chipReset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 4px 0 0;
|
||||
}
|
||||
.chipReset:hover {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.chipMain:focus-visible,
|
||||
.chipReset:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-small);
|
||||
}
|
||||
|
||||
/* List area with the progress stripe over its top edge. */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stripe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
/* New UI selection: rounded, inset 13px; the row's text 19px from the edges. */
|
||||
.list {
|
||||
--list-inset: 13px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0 -2px;
|
||||
padding: 4px 0 5px;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.left {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.right {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.unread {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--gh-unread);
|
||||
}
|
||||
.title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 30px;
|
||||
overflow: hidden;
|
||||
color: var(--fg-strong);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
}
|
||||
.tintedIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
--icon-color: currentColor;
|
||||
}
|
||||
.comments {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-right: 1px;
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.info {
|
||||
overflow: hidden;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyLink {
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
}
|
||||
.emptyLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.errorBody {
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Hover balloons (labels, assignees, reviewers). */
|
||||
.balloon {
|
||||
padding: 8px 10px 6px;
|
||||
background: var(--bg-tooltip);
|
||||
color: var(--fg-tooltip);
|
||||
}
|
||||
.balloonTitle {
|
||||
padding: 0 10px 4px 0;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.balloonRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* The list tab's panel (IntelliJ's `GHPRListPanelFactory` / `GHPRListPanelController`):
|
||||
* the search panel, a progress stripe while loading, and the pull requests, 50 per page,
|
||||
* the next page requested once the scroll passes 70% (checked again after each page).
|
||||
* Enter or a double-click opens the selected pull request; the context menu is
|
||||
* `Github.PullRequest.ToolWindow.List.Popup`. Load errors replace the list with
|
||||
* "Can't load pull requests" and Retry.
|
||||
*
|
||||
* The search starts as the last one used, else "Assigned to you"; every change is saved
|
||||
* as the last search and, by IntelliJ's rules, added to the history (`model.ts`).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, type MouseEvent } from "react";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { EmptyState, List, ProgressBar, showContextMenu, T, type MenuItem } from "@/ui";
|
||||
import type { GHPullRequestShort } from "../api/types";
|
||||
import { loadMore, reloadList, setListSearch, useGhPrList } from "../data/list";
|
||||
import { filterCount, type PrSearchValue } from "../data/model";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { initialSearch, rememberSearch, saveLastSearch } from "../data/searchHistory";
|
||||
import { openPullRequest } from "../toolwindow/open";
|
||||
import { setListSelection, useGhTabs } from "../toolwindow/store";
|
||||
import { createSearchHistoryRecorder, defaultFilter, pastThreshold, type SearchHistoryRecorder } from "./model";
|
||||
import { PullRequestRow } from "./PullRequestRow";
|
||||
import { SearchPanel } from "./SearchPanel";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
/** Row height: 4px + the 24px first line + the 16px info line + 5px. */
|
||||
const ROW_HEIGHT = 49;
|
||||
|
||||
/** `Github.PullRequest.ToolWindow.List.Popup`. */
|
||||
function contextMenu(number: number): MenuItem[] {
|
||||
return [
|
||||
{ kind: "action", action: "github.pullRequest.show", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.checkoutBranch", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.openInWorktree", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.openInBrowser", arg: number },
|
||||
{ kind: "action", action: "github.pullRequest.copyUrl", arg: number },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "github.refresh", label: "Refresh List" },
|
||||
];
|
||||
}
|
||||
|
||||
/** Applies searches to the list, saving them as IntelliJ does; the first call is the initial search. */
|
||||
function useApplySearch(login: string | undefined): (v: PrSearchValue) => void {
|
||||
const recorder = useRef<SearchHistoryRecorder | null>(null);
|
||||
useEffect(() => {
|
||||
if (!login) return;
|
||||
const defaultSearch = defaultFilter(login);
|
||||
const r = createSearchHistoryRecorder({
|
||||
defaultSearch,
|
||||
saveLast: (v) => saveLastSearch(v),
|
||||
remember: (v) => rememberSearch(v, undefined, defaultSearch),
|
||||
});
|
||||
recorder.current = r;
|
||||
r.onChange(initialSearch(login));
|
||||
void setListSearch(initialSearch(login));
|
||||
return () => {
|
||||
r.dispose();
|
||||
if (recorder.current === r) recorder.current = null;
|
||||
};
|
||||
}, [login]);
|
||||
return useCallback((v: PrSearchValue) => {
|
||||
recorder.current?.onChange(v);
|
||||
void setListSearch(v);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function ErrorState({ error }: { error: string }) {
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<EmptyState
|
||||
title="Can't load pull requests"
|
||||
actions={
|
||||
<button type="button" className={styles.emptyLink} onClick={() => void reloadList()}>
|
||||
<T>Retry</T>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className={styles.errorBody}>{error}</div>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListPanel() {
|
||||
const t = useTx();
|
||||
const connection = useGhRepository((s) => s.connection);
|
||||
const login = connection?.currentUser.login;
|
||||
const { search, items, loading, error, loaded, hasMore } = useGhPrList();
|
||||
const selected = useGhTabs((s) => s.listSelection);
|
||||
const apply = useApplySearch(login);
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const list = body.current?.querySelector<HTMLElement>("[role=listbox]");
|
||||
if (!list || list.offsetParent === null) return;
|
||||
if (pastThreshold(list.scrollTop, list.clientHeight, list.scrollHeight)) void loadMore();
|
||||
}, []);
|
||||
// After each page: the list may still not fill the view (`ReviewListUtil.wrapWithLazyVerticalScroll`).
|
||||
useEffect(() => {
|
||||
if (loaded && !loading && hasMore && !error) {
|
||||
const id = requestAnimationFrame(checkScroll);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
}, [items.length, loaded, loading, hasMore, error, checkScroll]);
|
||||
|
||||
if (!connection || !login) return null;
|
||||
const index = items.findIndex((p) => p.number === selected);
|
||||
const onContextMenu = (pr: GHPullRequestShort, _i: number, e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setListSelection(pr.number);
|
||||
showContextMenu(e, contextMenu(pr.number));
|
||||
};
|
||||
|
||||
const empty = loading ? (
|
||||
<EmptyState title="Loading…" />
|
||||
) : filterCount(search) === 0 ? (
|
||||
<EmptyState title={t`No pull requests in ${connection.coordinates.name}`} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No pull requests matching filters"
|
||||
actions={
|
||||
<button type="button" className={styles.emptyLink} onClick={() => apply({})}>
|
||||
<T>Clear filters</T>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<SearchPanel search={search} apply={apply} login={login} />
|
||||
<div ref={body} className={styles.body} onScrollCapture={checkScroll}>
|
||||
{loading && <ProgressBar className={styles.stripe} aria-label="Loading…" />}
|
||||
{error ? (
|
||||
<ErrorState error={error} />
|
||||
) : (
|
||||
<List<GHPullRequestShort>
|
||||
items={items}
|
||||
getKey={(p) => p.id}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
activeIndex={index}
|
||||
onActiveChange={(i) => setListSelection(items[i]?.number ?? null)}
|
||||
onActivate={(p, _i, e) => {
|
||||
// Enter or a double-click (`CommonShortcuts.ENTER`, `DOUBLE_CLICK_1`).
|
||||
if (e.type === "click" && (e as MouseEvent).detail !== 2) return;
|
||||
void openPullRequest(p.number);
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
aria-label="Pull requests"
|
||||
className={styles.list}
|
||||
empty={empty}
|
||||
renderItem={(p) => <PullRequestRow pr={p} currentUser={connection.currentUser} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* A list row (`ReviewListCellRenderer` with `GHPRListComponentFactory.presentPR`).
|
||||
* First line, 6px apart: the unread dot, the title (full title as tooltip when cut off)
|
||||
* and the labels icon on the left; the state tag, the conflict icon, up to 2 assignee
|
||||
* and 2 reviewer avatars and the unresolved comment counter on the right. Second line,
|
||||
* one size smaller and grey: `#N · created {date}, by {login}`.
|
||||
*/
|
||||
import { memo, useMemo, type PointerEvent } from "react";
|
||||
import { Icon } from "@/ui";
|
||||
import type { GHPullRequestShort, GHUser } from "../api/types";
|
||||
import { usePullRequestSeen } from "../data/interaction";
|
||||
import { unresolvedThreadsCount } from "../data/model";
|
||||
import { Avatar, AvatarStack, AVATAR_SIZE, reviewerRingColor } from "../ui/Avatar";
|
||||
import { labelColor, StateTag } from "../ui/chips";
|
||||
import { prettyDate } from "../ui/prettyDate";
|
||||
import { HoverBalloon, type BalloonItem } from "./HoverBalloon";
|
||||
import {
|
||||
assigneesTitle,
|
||||
infoLine,
|
||||
labelsTitle,
|
||||
reviewersTitle,
|
||||
rowReviewers,
|
||||
unresolvedCommentsTooltip,
|
||||
} from "./model";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
/** The participant icons a row shows per group (`MAX_PARTICIPANT_ICONS`). */
|
||||
const MAX_AVATARS = 2;
|
||||
|
||||
/** Sets the full title as the tooltip only while it is cut off. */
|
||||
function titleTooltip(e: PointerEvent<HTMLElement>) {
|
||||
const el = e.currentTarget;
|
||||
if (el.scrollWidth > el.clientWidth) el.dataset.tooltip = el.textContent ?? "";
|
||||
else delete el.dataset.tooltip;
|
||||
}
|
||||
|
||||
export const PullRequestRow = memo(function PullRequestRow({
|
||||
pr,
|
||||
currentUser,
|
||||
}: {
|
||||
pr: GHPullRequestShort;
|
||||
currentUser: GHUser | null;
|
||||
}) {
|
||||
const seen = usePullRequestSeen(pr, currentUser);
|
||||
const reviewers = useMemo(() => rowReviewers(pr), [pr]);
|
||||
const labels = pr.labels.nodes;
|
||||
const assignees = pr.assignees.nodes;
|
||||
const comments = unresolvedThreadsCount(pr);
|
||||
|
||||
const labelItems: BalloonItem[] = labels.map((l) => ({
|
||||
key: l.id || l.name,
|
||||
icon: (
|
||||
<span className={styles.tintedIcon} style={{ color: labelColor(l) }}>
|
||||
<Icon name="branch-label" size={16} />
|
||||
</span>
|
||||
),
|
||||
text: l.name,
|
||||
}));
|
||||
const assigneeItems: BalloonItem[] = assignees.map((u) => ({
|
||||
key: u.id || u.login,
|
||||
icon: <Avatar url={u.avatarUrl} name={u.login} size={AVATAR_SIZE.BASE} title={null} />,
|
||||
text: u.login,
|
||||
}));
|
||||
const reviewerItems: BalloonItem[] = reviewers.map((r) => ({
|
||||
key: r.key,
|
||||
icon: (
|
||||
<Avatar
|
||||
url={r.avatarUrl}
|
||||
name={r.name}
|
||||
size={AVATAR_SIZE.OUTLINED}
|
||||
ring={reviewerRingColor(r.state)}
|
||||
title={null}
|
||||
/>
|
||||
),
|
||||
text: r.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles.row} data-pr={pr.number}>
|
||||
<div className={styles.line}>
|
||||
<div className={styles.left}>
|
||||
{!seen && <span className={styles.unread} aria-hidden data-unread />}
|
||||
<span className={styles.title} onPointerEnter={titleTooltip}>
|
||||
{pr.title}
|
||||
</span>
|
||||
{labels.length > 0 && (
|
||||
<HoverBalloon title={labelsTitle(labels.length)} items={labelItems} className={styles.icon}>
|
||||
<Icon name="branch-label" size={16} />
|
||||
</HoverBalloon>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<StateTag state={pr.state} isDraft={pr.isDraft} />
|
||||
{pr.mergeable === "CONFLICTING" && (
|
||||
<span className={styles.icon} data-tooltip="Pull request cannot be merged">
|
||||
<Icon name="review-non-mergeable" size={16} />
|
||||
</span>
|
||||
)}
|
||||
{assignees.length > 0 && (
|
||||
<HoverBalloon title={assigneesTitle(assignees.length)} items={assigneeItems} className={styles.icon}>
|
||||
<AvatarStack
|
||||
max={MAX_AVATARS}
|
||||
size={AVATAR_SIZE.BASE}
|
||||
items={assignees.map((u) => ({ key: u.id || u.login, url: u.avatarUrl, name: u.login }))}
|
||||
title={null}
|
||||
/>
|
||||
</HoverBalloon>
|
||||
)}
|
||||
{reviewers.length > 0 && (
|
||||
<HoverBalloon title={reviewersTitle(reviewers.length)} items={reviewerItems} className={styles.icon}>
|
||||
<AvatarStack
|
||||
max={MAX_AVATARS}
|
||||
size={AVATAR_SIZE.OUTLINED}
|
||||
items={reviewers.map((r) => ({
|
||||
key: r.key,
|
||||
url: r.avatarUrl,
|
||||
name: r.name,
|
||||
ring: reviewerRingColor(r.state),
|
||||
}))}
|
||||
title={null}
|
||||
/>
|
||||
</HoverBalloon>
|
||||
)}
|
||||
<span className={styles.comments} data-tooltip={unresolvedCommentsTooltip(comments)}>
|
||||
<Icon name="review-comment" size={16} />
|
||||
{comments}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.info}>{infoLine(pr.number, prettyDate(pr.createdAt), pr.author?.login)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* The list's search panel (`ReviewListSearchPanelFactory` / `GHPRSearchPanelFactory`):
|
||||
* the search field (Enter applies the text; the history icon or ⌥↓ opens the last 10
|
||||
* searches), then the Quick Filters funnel and a horizontally scrolling row of chips —
|
||||
* State, Author, Label, Assignee, Review, Sort.
|
||||
*
|
||||
* Chips are IntelliJ's `FilterComponent`: the filter's name and a chevron while unset,
|
||||
* else the value and a reset ✕; Delete / Backspace resets. The popup opens 4px below.
|
||||
* As in IntelliJ, closing a chip's popup without choosing resets the chip.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { Icon, IconButton, Menu, SearchField, showContextMenu, type MenuItem } from "@/ui";
|
||||
import type { GHLabel, GHUser } from "../api/types";
|
||||
import { filterCount, type PrSearchValue } from "../data/model";
|
||||
import { useAuthorCandidates, useRepositoryList } from "../data/repository";
|
||||
import { loadSearchHistory } from "../data/searchHistory";
|
||||
import type { BatchesState } from "../data/batches";
|
||||
import { LabelSwatch } from "../ui/chips";
|
||||
import { ChooserPopup, type ChooserItemPresentation } from "../ui/ChooserPopup";
|
||||
import {
|
||||
isQuickFilterSelected,
|
||||
quickFilters,
|
||||
REVIEW_OPTIONS,
|
||||
reviewText,
|
||||
searchHistoryText,
|
||||
SORT_OPTIONS,
|
||||
sortText,
|
||||
STATE_OPTIONS,
|
||||
stateText,
|
||||
withQuery,
|
||||
type ChipOption,
|
||||
} from "./model";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
type Apply = (v: PrSearchValue) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search field
|
||||
|
||||
function SearchTextField({ search, apply }: { search: PrSearchValue; apply: Apply }) {
|
||||
const applied = search.searchQuery ?? "";
|
||||
const [text, setText] = useState(applied);
|
||||
// The text follows the search when it changes elsewhere (history, Quick Filters, Clear).
|
||||
const [shown, setShown] = useState(applied);
|
||||
if (shown !== applied) {
|
||||
setShown(applied);
|
||||
setText(applied);
|
||||
}
|
||||
const field = useRef<HTMLDivElement>(null);
|
||||
const showHistory = () => {
|
||||
const r = field.current?.getBoundingClientRect();
|
||||
const history = loadSearchHistory();
|
||||
const items: MenuItem[] = history.length
|
||||
? history.map((v, i) => ({
|
||||
id: `h${i}`,
|
||||
label: searchHistoryText(v),
|
||||
verbatim: true,
|
||||
run: () => apply(v),
|
||||
}))
|
||||
: [{ label: "Nothing to show", disabled: true }];
|
||||
showContextMenu({ x: (r?.left ?? 0) + 2, y: r?.bottom ?? 0 }, items);
|
||||
};
|
||||
return (
|
||||
<div ref={field}>
|
||||
<SearchField
|
||||
value={text}
|
||||
onChange={setText}
|
||||
onHistory={showHistory}
|
||||
clearable={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
apply(withQuery(search, text));
|
||||
}}
|
||||
aria-label="Search pull requests"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Filters
|
||||
|
||||
function QuickFilterButton({ search, apply, login }: { search: PrSearchValue; apply: Apply; login: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = useRef<HTMLSpanElement>(null);
|
||||
const count = filterCount(search);
|
||||
const items: MenuItem[] = [
|
||||
{ kind: "header", label: "Quick Filters" },
|
||||
...quickFilters(login).map((q): MenuItem => ({
|
||||
id: q.id,
|
||||
label: q.title,
|
||||
checked: isQuickFilterSelected(search, q),
|
||||
run: () => apply(q.filter),
|
||||
})),
|
||||
{ kind: "separator" },
|
||||
...(count > 0 ? [{ id: "clear", label: tx`Clear ${count} Filters`, verbatim: true, run: () => apply({}) }] : []),
|
||||
];
|
||||
return (
|
||||
<span ref={anchor} className={styles.quickFilter}>
|
||||
<IconButton icon="filter" title="Quick Filters" pressed={open} onClick={() => setOpen((o) => !o)} />
|
||||
{count > 0 && <span className={styles.badge} aria-hidden data-badge />}
|
||||
<Menu open={open} onClose={() => setOpen(false)} anchor={anchor} items={items} aria-label="Quick Filters" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chips
|
||||
|
||||
type PopupRender = (anchor: HTMLElement, done: (value: string | undefined) => void) => ReactNode;
|
||||
|
||||
function FilterChip({
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
popup,
|
||||
}: {
|
||||
title: string;
|
||||
/** The chip's text for the current value; undefined when unset. */
|
||||
value: string | undefined;
|
||||
onChange: (value: string | undefined) => void;
|
||||
popup: PopupRender;
|
||||
}) {
|
||||
const t = useTx();
|
||||
const chip = useRef<HTMLSpanElement>(null);
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const set = value !== undefined;
|
||||
const name = t(title);
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
onChange(undefined);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span ref={chip} className={styles.chip} data-set={set || undefined} data-tooltip={title}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipMain}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={!!anchor}
|
||||
onClick={() => setAnchor(chip.current)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span className={styles.chipText}>{set ? value : name}</span>
|
||||
{!set && <Icon name="chevron-down" size={12} className={styles.chipChevron} />}
|
||||
</button>
|
||||
{set && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipReset}
|
||||
aria-label={t`Reset ${name}`}
|
||||
onClick={() => onChange(undefined)}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
)}
|
||||
{anchor &&
|
||||
popup(anchor, (v) => {
|
||||
setAnchor(null);
|
||||
onChange(v);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ROW = 28;
|
||||
/** List popups show up to 7 rows under the search field. */
|
||||
const optionsHeight = (n: number) => 44 + Math.min(n, 7) * ROW + 4;
|
||||
|
||||
/** `ChooserPopupUtil.showChooserPopup` / `showChooserPopupWithIncrementalLoading`: the chosen value, or undefined when closed without choosing. */
|
||||
function Chooser<T>({
|
||||
anchor,
|
||||
done,
|
||||
title,
|
||||
items,
|
||||
getKey,
|
||||
present,
|
||||
state,
|
||||
height,
|
||||
}: {
|
||||
anchor: HTMLElement;
|
||||
done: (item: T | undefined) => void;
|
||||
title: string;
|
||||
items: readonly T[];
|
||||
getKey: (item: T) => string;
|
||||
present: (item: T) => ChooserItemPresentation;
|
||||
state?: Pick<BatchesState<T>, "loading" | "error">;
|
||||
height?: number;
|
||||
}) {
|
||||
const chosen = useRef<{ item: T } | null>(null);
|
||||
return (
|
||||
<ChooserPopup<T>
|
||||
mode="single"
|
||||
anchor={anchor}
|
||||
items={items}
|
||||
getKey={getKey}
|
||||
present={present}
|
||||
loading={state?.loading}
|
||||
error={state?.error}
|
||||
height={height}
|
||||
aria-label={title}
|
||||
onChoose={(item) => (chosen.current = { item })}
|
||||
onClose={() => done(chosen.current?.item)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionChip<V extends string>({
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
text,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
options: readonly ChipOption<V>[];
|
||||
value: V | undefined;
|
||||
text: (v: V) => string;
|
||||
onChange: (v: V | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<FilterChip
|
||||
title={title}
|
||||
value={value === undefined ? undefined : text(value)}
|
||||
onChange={(v) => onChange(v as V | undefined)}
|
||||
popup={(anchor, done) => (
|
||||
<Chooser<ChipOption<V>>
|
||||
anchor={anchor}
|
||||
title={title}
|
||||
items={options}
|
||||
getKey={(o) => o.value}
|
||||
present={(o) => ({ text: tx(o.label ?? o.text) })}
|
||||
height={optionsHeight(options.length)}
|
||||
done={(o) => done(o?.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const presentUser = (u: GHUser): ChooserItemPresentation => ({ text: u.login, detail: u.name, avatarUrl: u.avatarUrl });
|
||||
const userKey = (u: GHUser) => u.id || u.login;
|
||||
|
||||
function AuthorChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) {
|
||||
const authors = useAuthorCandidates();
|
||||
return (
|
||||
<Chooser<GHUser>
|
||||
anchor={anchor}
|
||||
title="Author"
|
||||
items={authors.items}
|
||||
state={authors}
|
||||
getKey={userKey}
|
||||
present={presentUser}
|
||||
done={(u) => done(u?.login)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneeChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) {
|
||||
const assignees = useRepositoryList("assignees");
|
||||
return (
|
||||
<Chooser<GHUser>
|
||||
anchor={anchor}
|
||||
title="Assignee"
|
||||
items={assignees.items}
|
||||
state={assignees}
|
||||
getKey={userKey}
|
||||
present={presentUser}
|
||||
done={(u) => done(u?.login)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) {
|
||||
const labels = useRepositoryList("labels");
|
||||
return (
|
||||
<Chooser<GHLabel>
|
||||
anchor={anchor}
|
||||
title="Label"
|
||||
items={labels.items}
|
||||
state={labels}
|
||||
getKey={(l) => l.id || l.name}
|
||||
present={(l) => ({ text: l.name, leading: <LabelSwatch label={l} /> })}
|
||||
done={(l) => done(l?.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The chips' row: scrolls horizontally, fading the clipped edges (`GradientViewport`). */
|
||||
function ChipsRow({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [fade, setFade] = useState<"left" | "right" | "both" | undefined>(undefined);
|
||||
const update = () => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const left = el.scrollLeft > 0;
|
||||
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||
setFade(left && right ? "both" : left ? "left" : right ? "right" : undefined);
|
||||
};
|
||||
useEffect(() => {
|
||||
update();
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.chips}
|
||||
data-fade={fade}
|
||||
onScroll={update}
|
||||
onWheel={(e) => {
|
||||
// A vertical wheel scrolls the row sideways, as JBScrollPane's FORCE_HORIZONTAL_SCROLL.
|
||||
const el = ref.current;
|
||||
if (el && Math.abs(e.deltaY) > Math.abs(e.deltaX)) el.scrollLeft += e.deltaY;
|
||||
}}
|
||||
role="toolbar"
|
||||
aria-label="Filters"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SearchPanel({ search, apply, login }: { search: PrSearchValue; apply: Apply; login: string }) {
|
||||
const patch = (p: Partial<PrSearchValue>) => {
|
||||
const next: PrSearchValue = { ...search, ...p };
|
||||
for (const k of Object.keys(p) as (keyof PrSearchValue)[]) if (next[k] === undefined) delete next[k];
|
||||
apply(next);
|
||||
};
|
||||
return (
|
||||
<div className={styles.search}>
|
||||
<SearchTextField search={search} apply={apply} />
|
||||
<div className={styles.filterRow}>
|
||||
<QuickFilterButton search={search} apply={apply} login={login} />
|
||||
<ChipsRow>
|
||||
<OptionChip
|
||||
title="State"
|
||||
options={STATE_OPTIONS}
|
||||
value={search.state}
|
||||
text={stateText}
|
||||
onChange={(state) => patch({ state })}
|
||||
/>
|
||||
<FilterChip
|
||||
title="Author"
|
||||
value={search.author}
|
||||
onChange={(author) => patch({ author })}
|
||||
popup={(anchor, done) => <AuthorChooser anchor={anchor} done={done} />}
|
||||
/>
|
||||
<FilterChip
|
||||
title="Label"
|
||||
value={search.label}
|
||||
onChange={(label) => patch({ label })}
|
||||
popup={(anchor, done) => <LabelChooser anchor={anchor} done={done} />}
|
||||
/>
|
||||
<FilterChip
|
||||
title="Assignee"
|
||||
value={search.assignee}
|
||||
onChange={(assignee) => patch({ assignee })}
|
||||
popup={(anchor, done) => <AssigneeChooser anchor={anchor} done={done} />}
|
||||
/>
|
||||
<OptionChip
|
||||
title="Review"
|
||||
options={REVIEW_OPTIONS}
|
||||
value={search.reviewState}
|
||||
text={reviewText}
|
||||
onChange={(reviewState) => patch({ reviewState })}
|
||||
/>
|
||||
<OptionChip
|
||||
title="Sort"
|
||||
options={SORT_OPTIONS}
|
||||
value={search.sort}
|
||||
text={sortText}
|
||||
onChange={(sort) => patch({ sort })}
|
||||
/>
|
||||
</ChipsRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import * as ipc from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useContextMenuStore, type MenuItem } from "@/ui";
|
||||
import { setCurrentRepository } from "../api/client";
|
||||
import type { GHPullRequestShort, GHRepository, GHUser } from "../api/types";
|
||||
import { clearList, useGhPrList } from "../data/list";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { loadLastSearch, loadSearchHistory } from "../data/searchHistory";
|
||||
import { resetTabs, useGhTabs } from "../toolwindow/store";
|
||||
import { ListPanel } from "./ListPanel";
|
||||
import {
|
||||
createSearchHistoryRecorder,
|
||||
infoLine,
|
||||
isQuickFilterSelected,
|
||||
pastThreshold,
|
||||
quickFilters,
|
||||
rowReviewers,
|
||||
searchHistoryText,
|
||||
unresolvedCommentsTooltip,
|
||||
withQuery,
|
||||
} from "./model";
|
||||
|
||||
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
|
||||
const user = (login: string, name: string | null = null): GHUser => ({
|
||||
__typename: "User",
|
||||
id: `id-${login}`,
|
||||
login,
|
||||
url: "",
|
||||
avatarUrl: "",
|
||||
name,
|
||||
});
|
||||
const nodes = <T,>(n: T[]) => ({ nodes: n });
|
||||
|
||||
function pr(number: number, over: Partial<GHPullRequestShort> = {}): GHPullRequestShort {
|
||||
return {
|
||||
id: `PR${number}`,
|
||||
url: `https://github.com/org/app/pull/${number}`,
|
||||
number,
|
||||
title: `Pull request ${number}`,
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
author: user("ann"),
|
||||
createdAt: "2020-01-02T10:00:00Z",
|
||||
updatedAt: "2020-01-02T10:00:00Z",
|
||||
isReadByViewer: true,
|
||||
assignees: nodes([]),
|
||||
labels: nodes([]),
|
||||
reviewRequests: nodes([]),
|
||||
reviewThreads: nodes([]),
|
||||
reviews: nodes([]),
|
||||
reactions: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } as never,
|
||||
mergeable: "MERGEABLE",
|
||||
viewerCanUpdate: false,
|
||||
viewerCanReact: false,
|
||||
viewerDidAuthor: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("search model", () => {
|
||||
it("offers IntelliJ's quick filters, checked only on an exact match", () => {
|
||||
const q = quickFilters("me");
|
||||
expect(q.map((f) => f.title)).toEqual(["Open", "Your pull requests", "Assigned to you", "Review requests"]);
|
||||
expect(q[1]!.filter).toEqual({ state: "OPEN", author: "me" });
|
||||
expect(q[3]!.filter).toEqual({ state: "OPEN", reviewState: "AWAITING_REVIEW" });
|
||||
expect(isQuickFilterSelected({ state: "OPEN", assignee: "me" }, q[2]!)).toBe(true);
|
||||
expect(isQuickFilterSelected({ state: "OPEN", assignee: "me", sort: "NEWEST" }, q[2]!)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the history text like getShortText", () => {
|
||||
expect(
|
||||
searchHistoryText({
|
||||
searchQuery: "foo",
|
||||
state: "OPEN",
|
||||
label: "bug",
|
||||
assignee: "me",
|
||||
reviewState: "AWAITING_REVIEW",
|
||||
author: "x",
|
||||
sort: "NEWEST",
|
||||
}),
|
||||
).toBe('"foo" state:"Open" label:bug assignee:me reviewState:"Awaiting review" author:x sort:"Newest"');
|
||||
expect(searchHistoryText({ reviewState: "REVIEWED_BY_ME" })).toBe('reviewState:"Reviewed"');
|
||||
});
|
||||
|
||||
it("sets or clears the text", () => {
|
||||
expect(withQuery({ state: "OPEN" }, "fix")).toEqual({ state: "OPEN", searchQuery: "fix" });
|
||||
expect(withQuery({ state: "OPEN", searchQuery: "fix" }, "")).toEqual({ state: "OPEN" });
|
||||
});
|
||||
|
||||
it("saves searches by IntelliJ's rules, waiting 10 seconds for chip-only changes", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const saveLast = vi.fn();
|
||||
const remember = vi.fn();
|
||||
const r = createSearchHistoryRecorder({ defaultSearch: { state: "OPEN", assignee: "me" }, saveLast, remember });
|
||||
r.onChange({ state: "OPEN", assignee: "me" });
|
||||
expect(saveLast).toHaveBeenCalledTimes(1);
|
||||
r.onChange({ state: "OPEN", searchQuery: "a" });
|
||||
expect(remember).toHaveBeenLastCalledWith({ state: "OPEN", searchQuery: "a" });
|
||||
r.onChange({ state: "CLOSED", searchQuery: "a" });
|
||||
vi.advanceTimersByTime(5_000);
|
||||
r.onChange({ state: "MERGED", searchQuery: "a" });
|
||||
vi.advanceTimersByTime(9_999);
|
||||
expect(remember).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(remember).toHaveBeenLastCalledWith({ state: "MERGED", searchQuery: "a" });
|
||||
r.onChange({});
|
||||
r.onChange({ state: "OPEN", assignee: "me" });
|
||||
vi.advanceTimersByTime(20_000);
|
||||
expect(remember).toHaveBeenCalledTimes(2);
|
||||
expect(saveLast).toHaveBeenCalledTimes(6);
|
||||
r.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("row model", () => {
|
||||
it("orders reviewers approved → changes requested → commented → re-requested → needs review", () => {
|
||||
const review = (author: GHUser, state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED", at: number) =>
|
||||
({
|
||||
__typename: "PullRequestReview",
|
||||
id: `${author.login}${at}`,
|
||||
url: "",
|
||||
author,
|
||||
body: "",
|
||||
state,
|
||||
createdAt: new Date(at * 1000).toISOString(),
|
||||
viewerCanUpdate: false,
|
||||
}) as const;
|
||||
const p = pr(1, {
|
||||
reviews: nodes([
|
||||
review(user("c"), "COMMENTED", 1),
|
||||
review(user("a", "Ada"), "APPROVED", 2),
|
||||
review(user("r"), "COMMENTED", 3),
|
||||
review(user("x"), "CHANGES_REQUESTED", 4),
|
||||
]),
|
||||
reviewRequests: nodes([{ requestedReviewer: user("n") }, { requestedReviewer: user("r") }]),
|
||||
});
|
||||
expect(rowReviewers(p).map((r) => [r.name, r.state])).toEqual([
|
||||
["Ada", "APPROVED"],
|
||||
["x", "CHANGES_REQUESTED"],
|
||||
["c", "COMMENTED"],
|
||||
["r", "RE_REQUESTED"],
|
||||
["n", "NEEDS_REVIEW"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats the info line and the comment tooltip", () => {
|
||||
expect(infoLine(12, "Today", "ann")).toBe("#12 · created Today, by ann");
|
||||
expect(infoLine(12, "Today", null)).toBe("#12 · created Today");
|
||||
expect(unresolvedCommentsTooltip(0)).toBe("No unresolved comments");
|
||||
expect(unresolvedCommentsTooltip(1)).toBe("1 unresolved comment");
|
||||
expect(unresolvedCommentsTooltip(3)).toBe("3 unresolved comments");
|
||||
});
|
||||
|
||||
it("requests the next page past 70% of the scroll", () => {
|
||||
expect(pastThreshold(0, 300, 1000)).toBe(false);
|
||||
expect(pastThreshold(400, 300, 1000)).toBe(true);
|
||||
expect(pastThreshold(0, 300, 200)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const repository: GHRepository = {
|
||||
id: "R1",
|
||||
owner: { __typename: "User", id: "O1", login: "org" },
|
||||
nameWithOwner: "org/app",
|
||||
viewerPermission: "READ",
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
defaultBranchRef: { name: "main" },
|
||||
isFork: false,
|
||||
url: "https://github.com/org/app",
|
||||
sshUrl: "",
|
||||
};
|
||||
|
||||
describe("ListPanel", () => {
|
||||
let pages: GHPullRequestShort[][] = [];
|
||||
let queries: string[] = [];
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
queries = [];
|
||||
vi.spyOn(ipc.commands, "ghApi").mockImplementation(async (req) => {
|
||||
const body = JSON.parse(req.body!) as { variables: { query: string } };
|
||||
queries.push(body.variables.query);
|
||||
const page = pages.shift() ?? [];
|
||||
return {
|
||||
status: "ok",
|
||||
data: JSON.stringify({
|
||||
data: {
|
||||
search: {
|
||||
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
|
||||
nodes: page,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as never;
|
||||
});
|
||||
setCurrentRepository({ owner: "org", name: "app", id: "R1" });
|
||||
useGhRepository.setState({
|
||||
availability: { kind: "ready", repo: {} as never, login: "me" },
|
||||
remotes: [{ owner: "org", name: "app", remote: "origin", url: "" }],
|
||||
connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearList();
|
||||
useGhPrList.setState({ search: {} });
|
||||
useGhRepository.setState({ connection: null, availability: null, remotes: [] });
|
||||
setCurrentRepository(null);
|
||||
resetTabs();
|
||||
useContextMenuStore.setState({ menu: null });
|
||||
});
|
||||
|
||||
const mount = async () => {
|
||||
await act(async () => {
|
||||
render(<ListPanel />);
|
||||
});
|
||||
};
|
||||
|
||||
it("starts with Assigned to you and renders the rows", async () => {
|
||||
pages = [
|
||||
[
|
||||
pr(7, {
|
||||
title: "Fix the thing",
|
||||
isDraft: true,
|
||||
mergeable: "CONFLICTING",
|
||||
author: me,
|
||||
labels: nodes([{ id: "L", url: "", name: "bug", color: "ff0000" }]),
|
||||
reviewThreads: nodes([
|
||||
{ isResolved: false, isOutdated: false },
|
||||
{ isResolved: true, isOutdated: false },
|
||||
{ isResolved: false, isOutdated: true },
|
||||
]),
|
||||
assignees: nodes([user("a"), user("b"), user("c")]),
|
||||
}),
|
||||
pr(8, { state: "MERGED", author: null }),
|
||||
],
|
||||
];
|
||||
await mount();
|
||||
expect(queries[0]).toBe("type:pr repo:org/app is:open assignee:me");
|
||||
const rows = screen.getAllByRole("option");
|
||||
expect(rows).toHaveLength(2);
|
||||
const first = within(rows[0]!);
|
||||
expect(first.getByText("Fix the thing")).toBeTruthy();
|
||||
expect(first.getByText("DRAFT")).toBeTruthy();
|
||||
expect(rows[0]!.querySelector("[data-tooltip='Pull request cannot be merged']")).toBeTruthy();
|
||||
expect(rows[0]!.querySelector("[data-tooltip='1 unresolved comment']")?.textContent).toBe("1");
|
||||
expect(rows[0]!.querySelectorAll("[data-avatar]")).toHaveLength(2);
|
||||
// Authored by me, never seen: unread.
|
||||
expect(rows[0]!.querySelector("[data-unread]")).toBeTruthy();
|
||||
expect(first.getByText(/^#7 · created .+, by me$/)).toBeTruthy();
|
||||
expect(within(rows[1]!).getByText("MERGED")).toBeTruthy();
|
||||
expect(within(rows[1]!).getByText(/^#8 · created /).textContent).not.toContain(", by");
|
||||
expect(rows[1]!.querySelector("[data-unread]")).toBeNull();
|
||||
expect(loadLastSearch()).toEqual({ state: "OPEN", assignee: "me" });
|
||||
});
|
||||
|
||||
it("opens the context menu with IntelliJ's items on the clicked row", async () => {
|
||||
pages = [[pr(1), pr(2)]];
|
||||
await mount();
|
||||
fireEvent.contextMenu(screen.getAllByRole("option")[1]!);
|
||||
expect(useGhTabs.getState().listSelection).toBe(2);
|
||||
const items = useContextMenuStore.getState().menu!.items;
|
||||
expect(items.map((i: MenuItem) => ("action" in i ? i.action : i.kind))).toEqual([
|
||||
"github.pullRequest.show",
|
||||
"github.pullRequest.checkoutBranch",
|
||||
"github.pullRequest.openInWorktree",
|
||||
"github.pullRequest.openInBrowser",
|
||||
"github.pullRequest.copyUrl",
|
||||
"separator",
|
||||
"github.refresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the empty states and clears filters", async () => {
|
||||
pages = [[], []];
|
||||
await mount();
|
||||
expect(screen.getByText("No pull requests matching filters")).toBeTruthy();
|
||||
await act(async () => fireEvent.click(screen.getByText("Clear filters")));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app");
|
||||
expect(screen.getByText("No pull requests in app")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows load errors with Retry", async () => {
|
||||
vi.mocked(ipc.commands.ghApi).mockRejectedValueOnce(new Error("HTTP 502"));
|
||||
await mount();
|
||||
expect(screen.getByText("Can't load pull requests")).toBeTruthy();
|
||||
pages = [[pr(3)]];
|
||||
await act(async () => fireEvent.click(screen.getByText("Retry")));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies the text on Enter and keeps it in the history", async () => {
|
||||
pages = [[], []];
|
||||
await mount();
|
||||
const field = screen.getByRole("textbox", { name: "Search pull requests" });
|
||||
fireEvent.change(field, { target: { value: "crash" } });
|
||||
await act(async () => fireEvent.keyDown(field, { key: "Enter" }));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app (crash) is:open assignee:me");
|
||||
expect(loadSearchHistory()).toEqual([{ state: "OPEN", assignee: "me", searchQuery: "crash" }]);
|
||||
});
|
||||
|
||||
it("resets a chip with its ✕ and with Delete, and shows the quick filter badge", async () => {
|
||||
pages = [[], [], []];
|
||||
await mount();
|
||||
expect(document.querySelector("[data-badge]")).toBeTruthy();
|
||||
const assignee = screen.getByText("me").closest("button")!;
|
||||
await act(async () => fireEvent.keyDown(assignee, { key: "Delete" }));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app is:open");
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Reset State" })));
|
||||
expect(queries[2]).toBe("type:pr repo:org/app");
|
||||
expect(document.querySelector("[data-badge]")).toBeNull();
|
||||
expect(screen.getByText("State")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("chooses a chip value from its popup", async () => {
|
||||
pages = [[], []];
|
||||
await mount();
|
||||
await act(async () => fireEvent.click(screen.getByText("Sort")));
|
||||
await act(async () => fireEvent.click(screen.getByText("Least recently updated")));
|
||||
expect(queries[1]).toBe("type:pr repo:org/app is:open assignee:me sort:updated-asc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Pure rules of the pull request list, ported from IntelliJ: the Quick Filters
|
||||
* (`GHPRListQuickFilter`), the chips' option lists and texts (`GHPRSearchPanelFactory`),
|
||||
* the search history's text (`getShortText`) and save rule
|
||||
* (`ReviewListSearchPanelViewModelBase`), the row's presentation
|
||||
* (`GHPRListComponentFactory`) and the 70% paging threshold (`ReviewListUtil`).
|
||||
*/
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { GHPRReviewerState, GHPullRequestRequestedReviewer, GHPullRequestShort } from "../api/types";
|
||||
import {
|
||||
filterCount,
|
||||
isTeam,
|
||||
sameSearch,
|
||||
sortedReviewersStates,
|
||||
type PrSearchReviewState,
|
||||
type PrSearchSort,
|
||||
type PrSearchState,
|
||||
type PrSearchValue,
|
||||
} from "../data/model";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Filters
|
||||
|
||||
export type QuickFilterId = "OPEN" | "YOURS" | "ASSIGNED" | "REVIEW_REQUESTS";
|
||||
|
||||
export interface QuickFilter {
|
||||
id: QuickFilterId;
|
||||
title: string;
|
||||
filter: PrSearchValue;
|
||||
}
|
||||
|
||||
/** The Quick Filters of the signed-in user, in menu order. */
|
||||
export function quickFilters(login: string): QuickFilter[] {
|
||||
return [
|
||||
{ id: "OPEN", title: "Open", filter: { state: "OPEN" } },
|
||||
{ id: "YOURS", title: "Your pull requests", filter: { state: "OPEN", author: login } },
|
||||
{ id: "ASSIGNED", title: "Assigned to you", filter: { state: "OPEN", assignee: login } },
|
||||
{ id: "REVIEW_REQUESTS", title: "Review requests", filter: { state: "OPEN", reviewState: "AWAITING_REVIEW" } },
|
||||
];
|
||||
}
|
||||
|
||||
/** The list's first-run filter and the one the history never keeps: "Assigned to you". */
|
||||
export const defaultFilter = (login: string): PrSearchValue => ({ state: "OPEN", assignee: login });
|
||||
|
||||
/** A quick filter is checked only when the search is exactly it. */
|
||||
export const isQuickFilterSelected = (search: PrSearchValue, q: QuickFilter) => sameSearch(search, q.filter);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chips
|
||||
|
||||
export interface ChipOption<V extends string> {
|
||||
value: V;
|
||||
/** The chip's text (and the history's). */
|
||||
text: string;
|
||||
/** The popup's text, when it differs. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const STATE_OPTIONS: ChipOption<PrSearchState>[] = [
|
||||
{ value: "OPEN", text: "Open" },
|
||||
{ value: "CLOSED", text: "Closed" },
|
||||
{ value: "MERGED", text: "Merged" },
|
||||
];
|
||||
|
||||
export const REVIEW_OPTIONS: ChipOption<PrSearchReviewState>[] = [
|
||||
{ value: "NO_REVIEW", text: "No reviews" },
|
||||
{ value: "REQUIRED", text: "Review required" },
|
||||
{ value: "APPROVED", text: "Approved review" },
|
||||
{ value: "CHANGES_REQUESTED", text: "Changes requested" },
|
||||
{ value: "REVIEWED_BY_ME", text: "Reviewed", label: "Reviewed by you" },
|
||||
{ value: "NOT_REVIEWED_BY_ME", text: "Not reviewed", label: "Not reviewed by you" },
|
||||
{ value: "AWAITING_REVIEW", text: "Awaiting review", label: "Awaiting review from you" },
|
||||
];
|
||||
|
||||
export const SORT_OPTIONS: ChipOption<PrSearchSort>[] = [
|
||||
{ value: "NEWEST", text: "Newest" },
|
||||
{ value: "OLDEST", text: "Oldest" },
|
||||
{ value: "MOST_COMMENTED", text: "Most commented" },
|
||||
{ value: "LEAST_COMMENTED", text: "Least commented" },
|
||||
{ value: "RECENTLY_UPDATED", text: "Recently updated" },
|
||||
{ value: "LEAST_RECENTLY_UPDATED", text: "Least recently updated" },
|
||||
];
|
||||
|
||||
const optionText = <V extends string>(options: readonly ChipOption<V>[], v: V) =>
|
||||
tx(options.find((o) => o.value === v)?.text ?? v);
|
||||
|
||||
export const stateText = (v: PrSearchState) => optionText(STATE_OPTIONS, v);
|
||||
export const reviewText = (v: PrSearchReviewState) => optionText(REVIEW_OPTIONS, v);
|
||||
export const sortText = (v: PrSearchSort) => optionText(SORT_OPTIONS, v);
|
||||
|
||||
/**
|
||||
* The history popup's text of a search (`getShortText`), e.g.
|
||||
* `"foo" state:"Open" label:bug assignee:me reviewState:"Awaiting review" author:x sort:"Newest"`.
|
||||
*/
|
||||
export function searchHistoryText(v: PrSearchValue): string {
|
||||
const parts: string[] = [];
|
||||
if (v.searchQuery !== undefined) parts.push(`"${v.searchQuery}"`);
|
||||
if (v.state) parts.push(`state:"${stateText(v.state)}"`);
|
||||
if (v.label !== undefined) parts.push(`label:${v.label}`);
|
||||
if (v.assignee !== undefined) parts.push(`assignee:${v.assignee}`);
|
||||
if (v.reviewState) parts.push(`reviewState:"${reviewText(v.reviewState)}"`);
|
||||
if (v.author !== undefined) parts.push(`author:${v.author}`);
|
||||
if (v.sort) parts.push(`sort:"${sortText(v.sort)}"`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** `v` with the text set (empty text clears it, as Enter in the field). */
|
||||
export function withQuery(v: PrSearchValue, text: string): PrSearchValue {
|
||||
const { searchQuery: _, ...rest } = v;
|
||||
return text ? { ...rest, searchQuery: text } : rest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History saving
|
||||
|
||||
/** IntelliJ waits this long before keeping a search whose text did not change (chip-only changes). */
|
||||
export const CHIP_ONLY_SAVE_DELAY_MS = 10_000;
|
||||
|
||||
export interface SearchHistoryRecorder {
|
||||
/** The search changed to `v` (the first call is the initial value). */
|
||||
onChange(v: PrSearchValue): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ReviewListSearchPanelViewModelBase.updateHistoryOnSearchChanges`: every value is saved
|
||||
* as the last filter; after the first, a value with filters that is not `defaultSearch` is
|
||||
* added to the history, at once when its text changed, else after 10 seconds unless the
|
||||
* search changes again in the meantime.
|
||||
*/
|
||||
export function createSearchHistoryRecorder(opts: {
|
||||
defaultSearch: PrSearchValue;
|
||||
saveLast: (v: PrSearchValue) => void;
|
||||
remember: (v: PrSearchValue) => void;
|
||||
delayMs?: number;
|
||||
}): SearchHistoryRecorder {
|
||||
let previous: PrSearchValue | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return {
|
||||
onChange(v) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
const old = previous;
|
||||
previous = v;
|
||||
opts.saveLast(v);
|
||||
if (!old) return;
|
||||
if (filterCount(v) === 0 || sameSearch(v, opts.defaultSearch)) return;
|
||||
if (old.searchQuery === v.searchQuery)
|
||||
timer = setTimeout(() => opts.remember(v), opts.delayMs ?? CHIP_ONLY_SAVE_DELAY_MS);
|
||||
else opts.remember(v);
|
||||
},
|
||||
dispose() {
|
||||
clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rows
|
||||
|
||||
/** `NamedCollection` titles: "Label" / "Labels" and so on, by count. */
|
||||
export function labelsTitle(n: number) {
|
||||
return n === 1 ? tx("Label") : tx("Labels");
|
||||
}
|
||||
export function assigneesTitle(n: number) {
|
||||
return n === 1 ? tx("Assignee") : tx("Assignees");
|
||||
}
|
||||
export function reviewersTitle(n: number) {
|
||||
return n === 1 ? tx("Reviewer") : tx("Reviewers");
|
||||
}
|
||||
|
||||
/** The comment counter's tooltip (`pull.request.unresolved.comments`). */
|
||||
export function unresolvedCommentsTooltip(count: number): string {
|
||||
if (count === 0) return tx("No unresolved comments");
|
||||
return count === 1 ? tx`${count} unresolved comment` : tx`${count} unresolved comments`;
|
||||
}
|
||||
|
||||
/** The second line: `#N · created {date}, by {login}`, or without the author. */
|
||||
export function infoLine(number: number, date: string, author: string | null | undefined): string {
|
||||
const id = `#${number}`;
|
||||
return author ? tx`${id} · created ${date}, by ${author}` : tx`${id} · created ${date}`;
|
||||
}
|
||||
|
||||
export interface ReviewerPresentation {
|
||||
key: string;
|
||||
/** Avatar tooltip / list text: the full name, else the login (team: name, else slug). */
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
state: GHPRReviewerState;
|
||||
}
|
||||
|
||||
function reviewerPresentation(r: GHPullRequestRequestedReviewer, state: GHPRReviewerState): ReviewerPresentation {
|
||||
if (isTeam(r)) return { key: r.id || r.combinedSlug, name: r.teamName || r.slug, avatarUrl: r.teamAvatarUrl, state };
|
||||
return { key: r.id || r.login, name: r.name || r.login, avatarUrl: r.avatarUrl, state };
|
||||
}
|
||||
|
||||
/** The row's reviewers in display order: approved → changes requested → commented → re-requested → needs review. */
|
||||
export function rowReviewers(pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">) {
|
||||
return sortedReviewersStates(pr).map((r) => reviewerPresentation(r.reviewer, r.state));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paging
|
||||
|
||||
/** `BoundedRangeModelThresholdListener(0.7)`: the visible part reaches 70% of the content (or there is no scroll). */
|
||||
export function pastThreshold(scrollTop: number, clientHeight: number, scrollHeight: number, threshold = 0.7): boolean {
|
||||
if (scrollHeight <= 0) return true;
|
||||
return (scrollTop + clientHeight) / scrollHeight >= threshold;
|
||||
}
|
||||
Reference in new issue
Block a user