mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(github): pull request tool window, timeline and review flow (#477)
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication. - List with search, quick filters and paging; a tab per pull request and a New Pull Request tab. - Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen. - Timeline editor: comments, review threads, reactions and suggested changes. - Review in the diff viewer and, for the checked-out branch, in the editor (review mode). - New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands. - CSP: img-src allows https://avatars.githubusercontent.com for avatars. Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane. Co-authored-by: imacross <[email protected]>
This commit is contained in:
1 parent
696377acc2
commit
059fd8a69b
192 files changed
+23668
-2076
No files matched your search
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* The merge direction (git4idea's `MergeDirectionComponentFactory`): two link-style
|
||||
* dropdowns, the base and the head, joined by "←". The base lists the branches of the
|
||||
* connected repository's remote; the head lists the local branches and the branches of
|
||||
* the chosen repository (the project's GitHub remotes and forks).
|
||||
*/
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { HighlightedText, Icon, List, Popup, SearchField, Select, T, fuzzyMatch, listNavigate } from "@/ui";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { nameWithOwner, sameRepository, useGhRepository, type GHRemote } from "../data/repository";
|
||||
import { ChooserPopup } from "../ui/ChooserPopup";
|
||||
import {
|
||||
baseText,
|
||||
headBranchName,
|
||||
headText,
|
||||
localBranchesOf,
|
||||
remoteBranchesOf,
|
||||
sameHead,
|
||||
type HeadBranch,
|
||||
type RemoteBranchRef,
|
||||
} from "./model";
|
||||
import { setBaseBranch, setHead, useCreatePr } from "./store";
|
||||
import styles from "./Create.module.css";
|
||||
|
||||
function DropDownLink(props: { text: string | null; label: string; onClick: (el: HTMLElement) => void }) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
aria-label={tx(props.label)}
|
||||
title={props.text ?? undefined}
|
||||
onClick={(e) => props.onClick(e.currentTarget)}
|
||||
>
|
||||
<span className={styles.linkText}>{props.text ?? tx("Select…")}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectionSelector() {
|
||||
const direction = useCreatePr((s) => s.direction);
|
||||
const branches = useCreatePr((s) => s.branches);
|
||||
const [open, setOpen] = useState<{ which: "base" | "head"; anchor: HTMLElement } | null>(null);
|
||||
const baseBranches = useMemo(
|
||||
() => (direction ? remoteBranchesOf(branches, direction.baseRepo.remote) : []),
|
||||
[branches, direction],
|
||||
);
|
||||
if (!direction) return null;
|
||||
return (
|
||||
<div className={styles.direction}>
|
||||
<DropDownLink
|
||||
text={baseText(direction)}
|
||||
label="Base branch"
|
||||
onClick={(anchor) => setOpen({ which: "base", anchor })}
|
||||
/>
|
||||
<span className={styles.arrow} aria-hidden>
|
||||
←
|
||||
</span>
|
||||
<DropDownLink
|
||||
text={headText(direction)}
|
||||
label="Head branch"
|
||||
onClick={(anchor) => setOpen({ which: "head", anchor })}
|
||||
/>
|
||||
{open?.which === "base" && (
|
||||
<ChooserPopup<RemoteBranchRef>
|
||||
mode="single"
|
||||
anchor={open.anchor}
|
||||
items={baseBranches}
|
||||
getKey={(b) => b.name}
|
||||
present={(b) => ({ text: b.name, icon: "branch-label" })}
|
||||
placeholder="Search branches"
|
||||
aria-label="Base branch"
|
||||
onChoose={setBaseBranch}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
)}
|
||||
{open?.which === "head" && (
|
||||
<HeadPopup
|
||||
anchor={open.anchor}
|
||||
initialRepo={direction.headRepo ?? direction.baseRepo}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HeadRow = { section: string } | { branch: HeadBranch; text: string; match: number[] | null };
|
||||
|
||||
/** The head: the repository (several GitHub remotes only), then its branches, local first. */
|
||||
function HeadPopup(props: { anchor: HTMLElement; initialRepo: GHRemote; onClose: () => void }) {
|
||||
const tx = useTx();
|
||||
const remotes = useGhRepository((s) => s.remotes);
|
||||
const branches = useCreatePr((s) => s.branches);
|
||||
const current = useCreatePr((s) => s.direction?.headBranch ?? null);
|
||||
const [repo, setRepo] = useState(props.initialRepo);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [active, setActive] = useState(1);
|
||||
const rows = useMemo(() => {
|
||||
const q = filter.trim();
|
||||
const section = (title: string, list: HeadBranch[]): HeadRow[] => {
|
||||
const items = list.flatMap((branch) => {
|
||||
const text = headBranchName(branch);
|
||||
const match = q ? fuzzyMatch(text, q) : null;
|
||||
return q && !match ? [] : [{ branch, text, match }];
|
||||
});
|
||||
return items.length ? [{ section: title }, ...items] : [];
|
||||
};
|
||||
const remote = remoteBranchesOf(branches, repo.remote).map((b): HeadBranch => ({ kind: "remote", ...b }));
|
||||
return [...section(tx("Local"), localBranchesOf(branches)), ...section(tx("Remote"), remote)];
|
||||
}, [branches, repo, filter, tx]);
|
||||
const isSection = (r: HeadRow) => "section" in r;
|
||||
const index = Math.min(Math.max(active, 0), rows.length - 1);
|
||||
const choose = (r: HeadRow | undefined) => {
|
||||
if (!r || "section" in r) return;
|
||||
setHead(repo, r.branch);
|
||||
props.onClose();
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const next = listNavigate(e.key, { items: rows, active: index, wrap: true, pageSize: 7, isSection });
|
||||
if (next !== null && e.key !== "Home" && e.key !== "End") {
|
||||
e.preventDefault();
|
||||
setActive(next);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
choose(rows[index]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
onClose={props.onClose}
|
||||
role="dialog"
|
||||
aria-label={tx("Head branch")}
|
||||
className={styles.headPopup}
|
||||
>
|
||||
{remotes.length > 1 && (
|
||||
<div className={styles.headRepo}>
|
||||
<span className={styles.headRepoLabel}>
|
||||
<T>Repository:</T>
|
||||
</span>
|
||||
<Select
|
||||
value={nameWithOwner(repo)}
|
||||
options={remotes.map((r) => ({ value: nameWithOwner(r), label: `${nameWithOwner(r)} (${r.remote})` }))}
|
||||
onChange={(v) => {
|
||||
const next = remotes.find((r) => nameWithOwner(r) === v);
|
||||
if (next && !sameRepository(next, repo)) setRepo(next);
|
||||
}}
|
||||
aria-label="Head repository"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.headSearch}>
|
||||
<SearchField
|
||||
value={filter}
|
||||
onChange={(v) => {
|
||||
setFilter(v);
|
||||
setActive(1);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={tx("Search branches")}
|
||||
aria-label="Search branches"
|
||||
data-autofocus
|
||||
/>
|
||||
</div>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r) => ("section" in r ? `s:${r.section}` : `${r.branch.kind}:${r.text}`)}
|
||||
isSection={isSection}
|
||||
renderSection={(r) => <div className={styles.headSection}>{"section" in r ? r.section : null}</div>}
|
||||
sectionHeight={22}
|
||||
activeIndex={index}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(r) => choose(r)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={24}
|
||||
className={styles.headList}
|
||||
aria-label="Branches"
|
||||
empty={<div className={styles.hint}>{tx("Nothing to show")}</div>}
|
||||
renderItem={(r, state) =>
|
||||
"section" in r ? null : (
|
||||
<div className={styles.headRow} data-active={state.active || undefined}>
|
||||
<Icon name={sameHead(r.branch, current) ? "current-branch-label" : "branch-label"} size={16} />
|
||||
<HighlightedText text={r.text} indices={r.match} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user