mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-15 08:08:18 +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 <30753807+imacross@users.noreply.github.com>
This commit is contained in:
@@ -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];
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user