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 <30753807+imacross@users.noreply.github.com>
This commit is contained in:
imacross
2026-10-31 07:50:43 +08:00
committed by GitHub
parent 696377acc2
commit 059fd8a69b
192 changed files with 23668 additions and 2076 deletions
@@ -0,0 +1,297 @@
/**
* The Pull Requests tool window shell (IntelliJ's `GHPRToolWindowFactory` /
* `GHPRToolWindowTabComponentFactory`): gh availability states, the repository selector
* (projects with several GitHub remotes), then the content tabs — the list tab named after
* the repository, `#N` tabs and "New Pull Request at {repo}" — in the header.
*/
import { useEffect, useRef, useState, type RefObject } from "react";
import type { GhAvailability } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import { Button, EmptyState, IconButton, Select, Spinner, Tabs, T, showContextMenu, type MenuItem } from "@/ui";
import { CreatePanel } from "../create/CreatePanel";
import { DetailsPanel } from "../details/DetailsPanel";
import { ListPanel } from "../list/ListPanel";
import { reloadList, useGhPrList } from "../data/list";
import { cancelBusy, openInBrowser, useGhBusy } from "../data/local";
import { repositoryDisplayName } from "../data/model";
import { cachedDetails } from "../data/pullRequest";
import {
connect,
disconnect,
nameWithOwner,
refreshConnection,
sameRepository,
useGhRepository,
} from "../data/repository";
import { closeTab, selectTab, tabId, useGhTabs, type GhTabId } from "./store";
import styles from "./ToolWindow.module.css";
function Unavailable({ availability }: { availability: GhAvailability }) {
const retry = (
<Button size="small" onClick={() => void refreshConnection()}>
<T>Retry</T>
</Button>
);
switch (availability.kind) {
case "notInstalled":
return (
<EmptyState
title="GitHub CLI is not installed"
actions={
<>
<Button size="small" onClick={() => void openInBrowser("https://cli.github.com")}>
<T>Install gh…</T>
</Button>
{retry}
</>
}
>
<T>Pull requests use the</T> <code>gh</code> <T>command line tool. Install it, then run</T>{" "}
<code>gh auth login</code>.
</EmptyState>
);
case "notLoggedIn":
return (
<EmptyState
title="Not logged in to GitHub"
actions={
<>
<Button
size="small"
variant="primary"
onClick={() =>
void executeAction("terminal.runCommand", {
program: "gh",
args: ["auth", "login"],
name: "gh auth login",
})
}
>
<T>Run gh auth login</T>
</Button>
{retry}
</>
}
>
<T>Run</T> <code>gh auth login</code> <T>in a terminal, then retry.</T>
</EmptyState>
);
case "notGitHub":
return (
<EmptyState title="No GitHub repository" actions={retry}>
<div>
<T>This project has no remote on GitHub.</T>
</div>
<div className={styles.hint}>{availability.message}</div>
</EmptyState>
);
case "error":
return (
<EmptyState title="Could not reach GitHub" actions={retry}>
{availability.message}
</EmptyState>
);
default:
return null;
}
}
/** IntelliJ's repository selector: the repositories (owner always shown) and "View Pull Requests". */
function RepositorySelector() {
const remotes = useGhRepository((s) => s.remotes);
const defaultRemote = useGhRepository((s) => s.defaultRemote);
const connecting = useGhRepository((s) => s.connecting);
const error = useGhRepository((s) => s.connectionError);
const [chosen, setChosen] = useState<string | null>(null);
const value =
chosen ??
nameWithOwner(remotes.find((r) => sameRepository(r, defaultRemote)) ?? remotes[0] ?? { owner: "", name: "" });
const target = remotes.find((r) => nameWithOwner(r) === value);
return (
<div className={styles.selector}>
<div className={styles.selectorRow}>
<Select
value={value}
options={remotes.map((r) => ({ value: nameWithOwner(r), label: repositoryDisplayName(remotes, r, true) }))}
onChange={setChosen}
aria-label="Repository"
/>
<Button
size="small"
variant="primary"
disabled={!target || connecting}
onClick={() => target && void connect(target)}
>
<T>View Pull Requests</T>
</Button>
{connecting && <Spinner size={14} />}
</div>
{error && <div className={styles.error}>{error}</div>}
</div>
);
}
function BusyBanner() {
const busy = useGhBusy((s) => s.busy);
if (!busy) return null;
return (
<div className={styles.banner} role="status">
<Spinner size={12} />
<span>{busy.title}</span>
<span className={styles.bannerText}>{busy.line}</span>
<IconButton icon="close" size="small" title="Cancel" onClick={cancelBusy} />
</div>
);
}
/** Calls `onShown` whenever `ref` becomes visible (the tool window shown, its tab selected). */
function useOnShown(ref: RefObject<HTMLElement | null>, onShown: () => void) {
const callback = useRef(onShown);
useEffect(() => {
callback.current = onShown;
});
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
let visible = el.offsetParent !== null;
const ro = new ResizeObserver(() => {
const now = el.offsetParent !== null;
if (now && !visible) callback.current();
visible = now;
});
ro.observe(el);
return () => ro.disconnect();
}, [ref]);
}
function Connected() {
const tabs = useGhTabs((s) => s.tabs);
const active = useGhTabs((s) => s.active);
const listRef = useRef<HTMLDivElement>(null);
// IntelliJ refreshes the list whenever its tab is selected or the window is shown with it.
useOnShown(listRef, () => {
if (useGhPrList.getState().loaded) void reloadList();
});
const pr = tabs.find((t) => tabId(t) === active);
return (
<div className={styles.root}>
<BusyBanner />
<div ref={listRef} className={`${styles.content} ${styles.listTab}`} hidden={active !== "list"}>
<ListPanel />
</div>
{pr?.kind === "pr" && (
<div className={styles.content}>
<DetailsPanel key={pr.number} number={pr.number} />
</div>
)}
{pr?.kind === "create" && (
<div className={styles.content}>
<CreatePanel />
</div>
)}
</div>
);
}
/** The tool window's content. */
export function PullRequestsToolWindow() {
const availability = useGhRepository((s) => s.availability);
const checking = useGhRepository((s) => s.checking);
const connected = useGhRepository((s) => !!s.connection);
const connecting = useGhRepository((s) => s.connecting);
useEffect(() => {
if (!useGhRepository.getState().availability) void refreshConnection();
}, []);
if (!availability)
return (
<div className={styles.loading}>
{checking && <Spinner size={14} />} {tx("Connecting to GitHub…")}
</div>
);
if (availability.kind !== "ready") return <Unavailable availability={availability} />;
if (connected) return <Connected />;
if (connecting)
return (
<div className={styles.loading}>
<Spinner size={14} /> {tx("Connecting to GitHub…")}
</div>
);
return <RepositorySelector />;
}
/** The tab's context menu: Close Tab / Close Other Tabs for `#N` and New Pull Request tabs. */
function tabMenu(id: GhTabId, all: GhTabId[]): MenuItem[] {
return [
{ label: "Close Tab", disabled: id === "list", run: () => closeTab(id) },
{
label: "Close Other Tabs",
disabled: all.filter((t) => t !== "list" && t !== id).length === 0,
run: () => all.forEach((t) => t !== id && closeTab(t)),
},
];
}
/** Header content: the closable content tabs while connected. */
export function PullRequestsTabs() {
const connection = useGhRepository((s) => s.connection);
const remotes = useGhRepository((s) => s.remotes);
const tabs = useGhTabs((s) => s.tabs);
const active = useGhTabs((s) => s.active);
if (!connection) return null;
const repo = repositoryDisplayName(remotes, connection.coordinates);
const ids: GhTabId[] = ["list", ...tabs.map(tabId)];
return (
<Tabs
variant="compact"
className={styles.tabs}
aria-label="Pull request tabs"
activeId={active}
onSelect={(id) => selectTab(id as GhTabId)}
onClose={(id) => closeTab(id as GhTabId)}
onContextMenu={(id, e) => showContextMenu(e, tabMenu(id as GhTabId, ids))}
items={[
{ id: "list", label: repo, tooltip: nameWithOwner(connection.coordinates), closable: false },
...tabs.map((t) =>
t.kind === "pr"
? {
id: tabId(t),
label: `#${t.number}`,
closable: true,
tooltip: cachedDetails(t.number)?.title,
}
: { id: tabId(t), label: tx`New Pull Request at ${repo}`, closable: true },
),
]}
/>
);
}
/** Title actions: Create Pull Request… (+) and Select Opened Pull Request, while connected. */
export function PullRequestsHeaderActions() {
const connected = useGhRepository((s) => !!s.connection);
if (!connected) return null;
return (
<>
<IconButton icon="plus" size="small" iconSize={16} action="github.createPullRequest" />
<IconButton icon="locate" size="small" iconSize={16} action="github.selectOpenedPullRequest" />
</>
);
}
/** The "⋮" menu's own items: Change Repository or Account… (connected, several GitHub remotes). */
export function usePullRequestsHeaderMenu(): MenuItem[] {
const connected = useGhRepository((s) => !!s.connection);
const several = useGhRepository((s) => s.remotes.length > 1);
return connected && several ? CHANGE_REPOSITORY : NONE;
}
const NONE: MenuItem[] = [];
const CHANGE_REPOSITORY: MenuItem[] = [
{ label: "Change Repository or Account…", run: () => void executeAction("github.changeRepository") },
];
/** "Change Repository or Account…": forgets the choice and shows the selector. */
export function changeRepository() {
disconnect({ forget: true });
}
@@ -0,0 +1,133 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.content[hidden] {
display: none;
}
/* IntelliJ's list tab: 8px above the search panel. */
.listTab {
padding-top: 8px;
}
.column {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.grow {
flex: 1;
min-height: 0;
}
.scroll {
height: 100%;
overflow: auto;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 100%;
color: var(--fg-secondary);
}
.hint {
margin-top: 4px;
color: var(--fg-secondary);
font-size: var(--fs-small);
}
/* Header */
.tabs {
min-width: 0;
}
.repoTitle {
overflow: hidden;
color: var(--fg-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
/* Repository selector (several GitHub remotes) */
.selector {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.selectorRow {
display: flex;
align-items: center;
gap: 8px;
}
.error {
color: var(--danger);
overflow-wrap: anywhere;
}
/* Busy banner (checkout, fetching for a diff) */
.banner {
display: flex;
flex: none;
align-items: center;
gap: 6px;
min-height: 26px;
padding: 0 6px 0 10px;
border-bottom: 1px solid var(--border);
background: var(--bg-elev);
font-size: var(--fs-small);
}
.bannerText {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--fg-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
/* Placeholder panels */
.plainRow {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: 0 13px;
overflow: hidden;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondary {
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.detailsTitle {
color: var(--fg-strong);
font-weight: 600;
}
.buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
}
.timelineStub {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 720px;
text-align: left;
}
@@ -0,0 +1,45 @@
/**
* Implementations of registered actions that live in other parts of the port (the
* details panel, review diff, review mode). A part provides its handler on activation;
* until then the action is disabled (`when` reads the `github.<name>` context key).
*
* const dispose = provideGithubHandler("submitReview", (number) => showSubmitReview(number));
*/
import { setContext } from "@/platform/context";
export interface GithubHandlers {
/** Submit Review… for the pull request (its pending review, if any). */
submitReview: (number: number) => void;
/** Toggle viewed/not viewed state of the selected files / the file in the active diff. */
toggleViewed: () => void;
/** Review mode: Show Pull Request in the Tool Window (the current branch's PR). */
showCurrentBranchPullRequest: () => void;
/** Review mode: Update to Enable Review Mode… */
updateReviewBranch: () => void;
/** Review mode: the Review Mode toggle. */
toggleReviewMode: () => void;
/** Review mode: whether it is on (the toggle's state). */
isReviewModeOn: () => boolean;
}
export type GithubHandlerName = keyof GithubHandlers;
const handlers: Partial<GithubHandlers> = {};
/** The context key enabling the action(s) of handler `name`. */
export const handlerContextKey = (name: GithubHandlerName) => `github.${name}`;
/** Provides handler `name`, enabling its actions; returns the disposer. */
export function provideGithubHandler<K extends GithubHandlerName>(name: K, handler: GithubHandlers[K]): () => void {
handlers[name] = handler;
setContext(handlerContextKey(name), true);
return () => {
if (handlers[name] !== handler) return;
delete handlers[name];
setContext(handlerContextKey(name), false);
};
}
export function githubHandler<K extends GithubHandlerName>(name: K): GithubHandlers[K] | undefined {
return handlers[name];
}
+41
View File
@@ -0,0 +1,41 @@
/** Opening the Pull Requests tool window, its tabs and the timeline editor. */
import { toolWindowService } from "@/workbench/contributions";
import { pullRequestData, reloadPullRequestData } from "../data/pullRequest";
import { refreshConnection, useGhRepository } from "../data/repository";
import { openCreateTab, openPullRequestTab } from "./store";
import { activeTimelinePullRequest, openTimeline } from "./timelineInput";
export const WINDOW = "pullRequests";
/** Shows the tool window, checking gh and connecting first when needed; whether it is connected. */
export async function showPullRequests(focus = true): Promise<boolean> {
toolWindowService().show(WINDOW, focus);
if (!useGhRepository.getState().connection) await refreshConnection();
return !!useGhRepository.getState().connection;
}
/**
* "View Pull Request" (Enter / double-click in the list): opens or selects the `#N` tab
* and opens the timeline editor without focusing it. Details loaded before are refreshed
* with the mergeability (`GHPROpenPullRequestAction`).
*/
export async function openPullRequest(number: number, opts: { timeline?: boolean } = {}) {
if (!(await showPullRequests(false))) return;
openPullRequestTab(number);
if (pullRequestData(number, "details").value) {
void reloadPullRequestData(number, "details").catch(() => undefined);
void reloadPullRequestData(number, "mergeability").catch(() => undefined);
}
if (opts.timeline ?? true) await openTimeline(number, { focus: false });
}
/** "Create Pull Request…": the New Pull Request tab. */
export async function openCreatePullRequest() {
if (await showPullRequests()) openCreateTab();
}
/** "Select Opened Pull Request": the tab of the pull request whose timeline is the active editor. */
export async function selectOpenedPullRequest() {
const n = activeTimelinePullRequest();
if (n !== null) await openPullRequest(n, { timeline: false });
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Content tabs of the Pull Requests tool window (IntelliJ's `ReviewToolwindowTabsManager`):
* the list tab (not closable), one `#N` tab per opened pull request and the "New Pull
* Request" tab, each opened at most once. Also the list's selection, which actions such as
* Checkout Pull Request… use.
*/
import { create } from "zustand";
import { setContext } from "@/platform/context";
export type GhTab = { kind: "pr"; number: number } | { kind: "create" };
export type GhTabId = "list" | `pr:${number}` | "create";
interface TabsState {
tabs: GhTab[];
active: GhTabId;
/** The pull request selected in the list. */
listSelection: number | null;
}
export const useGhTabs = create<TabsState>(() => ({ tabs: [], active: "list", listSelection: null }));
export const tabId = (t: GhTab): GhTabId => (t.kind === "pr" ? `pr:${t.number}` : "create");
/** Opens (or selects) the `#N` tab. */
export function openPullRequestTab(number: number) {
useGhTabs.setState((s) => ({
tabs: s.tabs.some((t) => t.kind === "pr" && t.number === number) ? s.tabs : [...s.tabs, { kind: "pr", number }],
active: `pr:${number}`,
}));
}
/** Opens (or selects) the "New Pull Request at {repo}" tab. */
export function openCreateTab() {
useGhTabs.setState((s) => ({
tabs: s.tabs.some((t) => t.kind === "create") ? s.tabs : [...s.tabs, { kind: "create" }],
active: "create",
}));
}
export function selectTab(id: GhTabId) {
useGhTabs.setState({ active: id });
}
/** Closes a `#N` or the New Pull Request tab; the tab before it becomes active. */
export function closeTab(id: GhTabId) {
if (id === "list") return;
useGhTabs.setState((s) => {
const i = s.tabs.findIndex((t) => tabId(t) === id);
if (i < 0) return s;
const tabs = s.tabs.filter((_, j) => j !== i);
const before = tabs[i - 1];
const active = s.active === id ? (before ? tabId(before) : "list") : s.active;
return { tabs, active };
});
}
export function setListSelection(number: number | null) {
useGhTabs.setState({ listSelection: number });
}
/** The pull request of the active `#N` tab, else the list's selection. */
export function contextPullRequest(): number | null {
const s = useGhTabs.getState();
const m = /^pr:(\d+)$/.exec(s.active);
return m ? Number(m[1]) : s.listSelection;
}
/** Another repository or project: only the list tab stays. */
export function resetTabs() {
useGhTabs.setState({ tabs: [], active: "list", listSelection: null });
}
/** `githubPrTab`: `list`, `pr` or `create`, for the tab-specific shortcuts (Refresh). */
useGhTabs.subscribe((s) => setContext("githubPrTab", s.active.startsWith("pr:") ? "pr" : s.active));
@@ -0,0 +1,60 @@
/**
* The pull request timeline editor tab (IntelliJ's `GHPRTimelineVirtualFile`): its input,
* title (`{title} #N` once the details are known, else `#N`) and state icon.
*/
import { editorService, maybeEditorService, type EditorInput } from "@/workbench/contributions";
import type { GHRepositoryCoordinates } from "../api/types";
import { pullRequestStateIcon } from "../data/model";
import { cachedDetails } from "../data/pullRequest";
import { requireConnection, sameRepository, useGhRepository } from "../data/repository";
export const TIMELINE_KIND = "github.timeline";
/** `input.data` of a timeline tab. */
export interface TimelineInputData extends GHRepositoryCoordinates {
number: number;
}
export function timelineInput(repo: GHRepositoryCoordinates, number: number): EditorInput {
const data: TimelineInputData = { owner: repo.owner, name: repo.name, number };
return { kind: TIMELINE_KIND, key: `${TIMELINE_KIND}:${repo.owner}/${repo.name}#${number}`, data };
}
/** The timeline tab's data, or null for another kind of input. */
export function timelineData(input: EditorInput | null | undefined): TimelineInputData | null {
if (input?.kind !== TIMELINE_KIND) return null;
const d = input.data as Partial<TimelineInputData> | undefined;
return d && typeof d.number === "number" && typeof d.owner === "string" && typeof d.name === "string"
? (d as TimelineInputData)
: null;
}
/** The details when the tab is of the connected repository and they are loaded. */
function detailsOf(input: EditorInput) {
const d = timelineData(input);
if (!d || !sameRepository(useGhRepository.getState().connection?.coordinates, d)) return undefined;
return cachedDetails(d.number);
}
export function timelineTitle(input: EditorInput): string {
const d = timelineData(input);
const details = detailsOf(input);
return details ? `${details.title} #${d!.number}` : `#${d?.number ?? "?"}`;
}
export function timelineIcon(input: EditorInput): string {
const details = detailsOf(input);
return details ? pullRequestStateIcon(details.state, details.isDraft) : "github-timeline";
}
/** "View Timeline": opens the timeline of `number` of the connected repository (`focus`: like a file open). */
export async function openTimeline(number: number, opts: { focus?: boolean } = {}): Promise<void> {
const repo = requireConnection().coordinates;
await editorService().openInput(timelineInput(repo, number), { focus: opts.focus ?? true });
}
/** The pull request whose timeline is the active editor tab, when it is of the connected repository. */
export function activeTimelinePullRequest(): number | null {
const d = timelineData(maybeEditorService()?.activeInput?.() ?? null);
return d && sameRepository(useGhRepository.getState().connection?.coordinates, d) ? d.number : null;
}
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { getAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import * as ipc from "@/platform/ipc";
import { useContributions } from "@/workbench/contributions";
import { activate } from "../index";
import { setCurrentRepository } from "../api/client";
import type { GHRepository, GHUser } from "../api/types";
import { useGhRepository } from "../data/repository";
import { PullRequestsTabs, PullRequestsToolWindow } from "./PullRequestsToolWindow";
import {
closeTab,
contextPullRequest,
openCreateTab,
openPullRequestTab,
resetTabs,
setListSelection,
useGhTabs,
} from "./store";
import { TIMELINE_KIND, timelineData, timelineInput } from "./timelineInput";
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
const repository: GHRepository = {
id: "R1",
owner: { __typename: "User", id: "O1", login: "org" },
nameWithOwner: "org/app",
viewerPermission: "WRITE",
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
defaultBranchRef: { name: "main" },
isFork: false,
url: "https://github.com/org/app",
sshUrl: "",
};
describe("tabs", () => {
beforeEach(() => resetTabs());
it("opens each pull request once and closes back to the previous tab", () => {
openPullRequestTab(5);
openPullRequestTab(7);
openPullRequestTab(5);
expect(useGhTabs.getState().tabs).toEqual([
{ kind: "pr", number: 5 },
{ kind: "pr", number: 7 },
]);
expect(useGhTabs.getState().active).toBe("pr:5");
expect(getContext().githubPrTab).toBe("pr");
openCreateTab();
openCreateTab();
expect(useGhTabs.getState().tabs).toHaveLength(3);
closeTab("create");
expect(useGhTabs.getState().active).toBe("pr:7");
closeTab("list");
expect(useGhTabs.getState().tabs).toHaveLength(2);
});
it("targets the active pull request tab, else the list selection", () => {
setListSelection(3);
expect(contextPullRequest()).toBe(3);
openPullRequestTab(9);
expect(contextPullRequest()).toBe(9);
closeTab("pr:9");
expect(useGhTabs.getState().active).toBe("list");
expect(contextPullRequest()).toBe(3);
});
it("keys timeline tabs by repository and number", () => {
const input = timelineInput({ owner: "org", name: "app" }, 12);
expect(input.kind).toBe(TIMELINE_KIND);
expect(timelineData(input)).toEqual({ owner: "org", name: "app", number: 12 });
expect(timelineData({ kind: "file", key: "x" })).toBeNull();
});
});
describe("tool window", () => {
let dispose: () => void = () => {};
beforeEach(() => {
dispose = activate();
});
afterEach(() => {
dispose();
vi.restoreAllMocks();
setCurrentRepository(null);
useGhRepository.setState({ connection: null, availability: null, remotes: [] });
resetTabs();
});
it("registers the window, the timeline editor and the actions", () => {
const c = useContributions.getState();
expect(c.toolWindows.get("pullRequests")?.title).toBe("Pull Requests");
expect(c.editorKinds.has(TIMELINE_KIND)).toBe(true);
for (const id of [
"github.pullRequests",
"github.createPullRequest",
"github.checkoutPullRequest",
"github.openInBrowser",
"github.refresh",
"github.pullRequest.show",
"github.pullRequest.showTimeline",
"github.pullRequest.refresh",
"github.pullRequest.timeline.refresh",
"github.pullRequest.openInBrowser",
"github.pullRequest.copyUrl",
"github.pullRequest.checkoutBranch",
"github.pullRequest.openInWorktree",
"github.pullRequest.submitReview",
"github.pullRequest.toggleViewed",
"github.selectOpenedPullRequest",
"github.changeRepository",
])
expect(getAction(id), id).toBeDefined();
});
it("shows gh's state when it is not ready", () => {
useGhRepository.setState({ availability: { kind: "notInstalled" } });
render(<PullRequestsToolWindow />);
expect(screen.getByText("GitHub CLI is not installed")).toBeTruthy();
});
it("offers the repository selector for several GitHub remotes", () => {
useGhRepository.setState({
availability: { kind: "ready", repo: {} as never, login: "me" },
remotes: [
{ owner: "me", name: "app", remote: "origin", url: "" },
{ owner: "org", name: "app", remote: "upstream", url: "" },
],
defaultRemote: { owner: "org", name: "app" },
});
render(<PullRequestsToolWindow />);
expect(screen.getByText("View Pull Requests")).toBeTruthy();
});
it("shows the list tab, named after the repository, once connected", async () => {
const ghApi = vi.spyOn(ipc.commands, "ghApi").mockResolvedValue({
status: "ok",
data: JSON.stringify({
data: {
search: {
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
nodes: [],
},
},
}),
} as never);
setCurrentRepository({ owner: "org", name: "app", id: "R1" });
await act(async () => {
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 },
});
render(
<>
<PullRequestsTabs />
<PullRequestsToolWindow />
</>,
);
});
expect(screen.getByRole("tab", { name: /app/ })).toBeTruthy();
expect(screen.getByRole("textbox", { name: "Search pull requests" })).toBeTruthy();
const body = JSON.parse(ghApi.mock.calls[0]![0].body!) as { variables: { query: string; pageSize: number } };
// First run: "Assigned to you".
expect(body.variables).toMatchObject({ query: "type:pr repo:org/app is:open assignee:me", pageSize: 50 });
await act(async () => openPullRequestTab(4));
expect(screen.getByRole("tab", { name: /#4/ })).toBeTruthy();
});
});