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:
imacross authored and GitHub committed 2026-10-31 07:50:43 +08:00
1 parent 696377acc2
commit 059fd8a69b
192 files changed
+23668 -2076

No files matched your search

+77
View File
@@ -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>
</>
);
}
+265
View File
@@ -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;
}
+159
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
});
+389
View File
@@ -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>
);
}
+330
View File
@@ -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");
});
});
+209
View File
@@ -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;
}