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]>
Цей коміт міститься в:
imacross authored and GitHub committed 2026-10-31 07:50:43 +08:00
1 джерело 696377acc2
коміт 059fd8a69b
192 files changed
+23668 -2076

No files matched your search

+47
Переглянути файл
@@ -0,0 +1,47 @@
/* The review group in the editor's top-right row, styled like the inspection widget. */
.toolbar {
display: flex;
align-items: center;
gap: 1px;
padding: 1px;
border-radius: var(--radius-small);
background-color: var(--bg-editor, var(--bg-island, var(--bg-app)));
font-family: var(--font-ui);
font-size: var(--fs-ui);
color: var(--fg-muted);
user-select: none;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 22px;
min-width: 22px;
padding: 0 3px;
border: none;
border-radius: var(--radius-small);
background: none;
color: inherit;
font: inherit;
}
/* "Review Mode" with its text: the toolbar's small font. */
.text {
padding: 0 5px;
font-size: var(--fs-small, var(--fs-ui));
}
.button:hover {
background-color: var(--bg-toolbar-hover);
}
.button:focus-visible {
outline: 1px solid var(--focus-ring);
outline-offset: -1px;
}
.warning {
color: var(--warning);
}
+98
Переглянути файл
@@ -0,0 +1,98 @@
/**
* The review group at the editor's top-right (`ReviewInEditorUtil.showReviewToolbarWithActions`
* with `CodeReviewInEditorToolbarActionGroup`): Previous / Next Comment, then "Review Mode"
* (a warning icon when the branch is out of sync; only the eye while review mode is off)
* opening its menu: "Update Branch to Enable Review Mode…" (out of sync), "Disable Review
* Mode", "Show Unresolved Comments Only", "Show All Comments".
*/
import { useRef, useState } from "react";
import clsx from "clsx";
import { tx } from "@/platform/i18n";
import { Icon, Menu, tooltipAttrs, type MenuItem } from "@/ui";
import type { ReviewEditor } from "./editor";
import { setEditorThreadsOption, setReviewModeEnabled, useReviewModeSettings } from "./settings";
import { updateReviewBranch, useReviewBranch } from "./state";
import styles from "./ReviewMode.module.css";
export function ReviewModeToolbar({ editor }: { editor: ReviewEditor }) {
const { enabled, threads } = useReviewModeSettings();
const updateRequired = useReviewBranch((s) => s.updateRequired);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const shown = enabled && !updateRequired;
const items: MenuItem[] = [
...(updateRequired
? [
{ label: "Update Branch to Enable Review Mode…", icon: "branch", run: () => void updateReviewBranch() },
{ kind: "separator" as const },
]
: []),
{ label: "Disable Review Mode", checked: !enabled, run: () => setReviewModeEnabled(false) },
{
label: "Show Unresolved Comments Only",
checked: enabled && threads === "unresolved",
run: () => {
setReviewModeEnabled(true);
setEditorThreadsOption("unresolved");
},
},
{
label: "Show All Comments",
checked: enabled && threads === "all",
run: () => {
setReviewModeEnabled(true);
setEditorThreadsOption("all");
},
},
];
return (
<div className={styles.toolbar} role="toolbar" aria-label={tx("Review Mode")}>
{shown && (
<>
<button
type="button"
className={styles.button}
aria-label={tx("Previous Comment")}
{...tooltipAttrs(tx("Previous Comment"))}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void editor.gotoComment(-1)}
>
<Icon name="arrow-up" size={14} />
</button>
<button
type="button"
className={styles.button}
aria-label={tx("Next Comment")}
{...tooltipAttrs(tx("Next Comment"))}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void editor.gotoComment(1)}
>
<Icon name="arrow-down" size={14} />
</button>
</>
)}
<button
ref={ref}
type="button"
className={clsx(styles.button, enabled && styles.text)}
aria-label={tx("Configure review mode")}
aria-haspopup="menu"
aria-expanded={open}
data-review-mode={enabled ? (updateRequired ? "outOfSync" : "on") : "off"}
{...tooltipAttrs(tx("In Review Mode, you can see review comments and proposed changes right in the editor"))}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setOpen((o) => !o)}
>
{enabled ? (
<>
{updateRequired && <Icon name="warning" size={14} className={styles.warning} />}
{tx("Review Mode")}
</>
) : (
<Icon name="eye" size={14} />
)}
</button>
<Menu open={open} onClose={() => setOpen(false)} anchor={ref} items={items} placement="bottom-end" />
</div>
);
}
+558
Переглянути файл
@@ -0,0 +1,558 @@
/**
* Review in the regular editor (IntelliJ's `GHPRReviewInEditorController` with
* `GHPRReviewFileEditorModel`): an editor contribution for the files of the current
* branch's pull request. Such an editor gets the "Review Mode" group at its top-right
* (`showReviewToolbarWithActions`: Previous / Next Comment and the mode's menu); while review
* mode is on and the branch is in sync it also gets
*
* - the review threads (right side, current) as inlays under their lines,
* - the gutter "+" on commentable lines (the patch's hunks) with the new-comment inlays the
* diff uses (Start Review / Add Single Comment / Add Review Comment),
* - change markers of the review's changes (vs the pull request's base) in the change lane,
* under the local ones.
*
* Lines are carried from the review head to the document through the diff of the head's
* text and the document (`trackDocumentDiffSync`), recomputed as the document changes.
*/
import { Compartment, RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { createRoot, type Root } from "react-dom/client";
import { LaneMarker, editorCorner, gutterLaneMarkers, useEditorRegistry, type EditorDocContext } from "@/editor/api";
import { lineInlays, setLineInlays, type LineInlay } from "@/features/vcs/lineInlays";
import {
EMPTY_REVIEW_GUTTER,
canCreateComment,
isLineCommentable,
reviewGutter,
setReviewGutterState,
type LineRange,
type ReviewGutterState,
type ReviewOutline,
} from "@/features/vcs/reviewGutter";
import { currentRepoRoot } from "@/features/vcs/store";
import { setContext } from "@/platform/context";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { maybeEditorService } from "@/workbench/contributions";
import type { GHCommitFile, GHPullRequestReviewThread } from "../api/types";
import { openPullRequestFileDiff } from "../data/local";
import {
loadPullRequestData,
pullRequestData,
reloadPullRequestData,
subscribePullRequestData,
} from "../data/pullRequest";
import { currentUserHasPermission, useGhRepository } from "../data/repository";
import { discardNewComment, NewCommentInlay, ThreadInlay } from "../diff/Inlays";
import { adjacentThread, newCommentLocation, parsePatch, threadOrder, type PatchHunk } from "../diff/model";
import { newCommentsOf, requestNewComment, updateNewComment, useNewComments } from "../diff/newComments";
import { containsSuggestedChange } from "../timeline/model";
import {
documentChanges,
editorThreadRange,
hunksToRanges,
lineFromAfter,
lineToAfter,
patchChanges,
patchRanges,
shiftRanges,
type ChangeKind,
type DocumentChange,
type Range,
} from "./model";
import { useReviewModeSettings } from "./settings";
import { useReviewBranch } from "./state";
import { ReviewModeToolbar } from "./Toolbar";
/** Parsed patches, by patch text. */
const hunkCache = new Map<string, PatchHunk[]>();
function hunksOf(patch: string | null | undefined): PatchHunk[] {
if (!patch) return [];
let h = hunkCache.get(patch);
if (!h) {
h = parsePatch(patch);
if (hunkCache.size > 200) hunkCache.clear();
hunkCache.set(patch, h);
}
return h;
}
/** Path relative to the repository root; null outside it. */
function repositoryPath(path: string): string | null {
const root = currentRepoRoot();
return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null;
}
// ---------------------------------------------------------------------------
// Review change markers
const setReviewChanges = StateEffect.define<readonly DocumentChange[]>();
const LANE: Record<ChangeKind, LaneMarker> = {
added: new LaneMarker("var(--gutter-added)"),
modified: new LaneMarker("var(--gutter-modified)"),
deleted: new LaneMarker("var(--gutter-deleted)", "deleted"),
};
const reviewChanges = StateField.define<RangeSet<GutterMarker>>({
create: () => RangeSet.empty,
update(value, tr) {
for (const e of tr.effects) {
if (!e.is(setReviewChanges)) continue;
const doc = tr.state.doc;
const ranges = [];
for (const c of e.value) {
if (c.kind === "deleted" || c.end <= c.start) {
ranges.push(LANE.deleted.range(doc.line(Math.min(Math.max(c.start, 0), doc.lines - 1) + 1).from));
continue;
}
for (let l = c.start; l < c.end && l < doc.lines; l++) ranges.push(LANE[c.kind].range(doc.line(l + 1).from));
}
return RangeSet.of(ranges, true);
}
return tr.docChanged ? value.map(tr.changes) : value;
},
});
/** The review's extensions, installed while it is shown. */
function reviewExtensions(editor: ReviewEditor): Extension {
return [
lineInlays(),
reviewGutter({
requestNewComment: (range) => editor.request(range),
cancelNewComment: (line) => editor.cancelAt(line),
toggleComments: (line) => editor.toggleAt(line),
adjustRange: (key, range) => editor.adjust(key, range),
}),
reviewChanges,
gutterLaneMarkers.from(reviewChanges),
];
}
// ---------------------------------------------------------------------------
const compartment = new Compartment();
const editors = new Map<EditorView, ReviewEditor>();
/** The review in the active text editor, when it is shown. */
export function activeReviewEditor(): ReviewEditor | null {
const view = useEditorRegistry.getState().active?.view;
const editor = view ? editors.get(view) : undefined;
return editor?.shown ? editor : null;
}
/** The review of the active text editor's file, shown or not (Show Review Diff). */
export function activeReviewFile(): ReviewEditor | null {
const view = useEditorRegistry.getState().active?.view;
const editor = view ? editors.get(view) : undefined;
return editor?.number != null ? editor : null;
}
/** Keeps `githubReviewEditor` (the active editor shows a review) up to date. */
export function trackActiveReviewEditor(): () => void {
const sync = () => setContext("githubReviewEditor", !!activeReviewEditor());
sync();
const unsub = useEditorRegistry.subscribe((s, prev) => {
if (s.active !== prev.active) sync();
});
return () => {
unsub();
setContext("githubReviewEditor", false);
};
}
/** The thread Previous / Next Comment moved to last (navigation goes on from it while the caret stays). */
let navigated: { number: number; id: string; path: string; line: number } | null = null;
/** `GHPRReviewFileEditorModel` for one editor. */
export class ReviewEditor {
/** The pull request file this editor shows, when it is one. */
number: number | null = null;
file: GHCommitFile | null = null;
/** The review is shown (review mode on, the branch in sync, the head's text known). */
shown = false;
/** The head revision's text, for `headOid`. */
private head: { oid: string; text: string | null } | null = null;
/** Document changes since the review head (`postReviewRanges`); null until computed. */
private local: Range[] | null = null;
private hidden = new Set<string>();
private gutter: ReviewGutterState = EMPTY_REVIEW_GUTTER;
private installed = false;
private toolbar: { el: HTMLElement; root: Root } | null = null;
private stopData: (() => void) | null = null;
private dataFor: number | null = null;
private disposers: Array<() => void> = [];
private diffTimer: ReturnType<typeof setTimeout> | undefined;
private diffSeq = 0;
private scheduled = false;
private destroyed = false;
constructor(
readonly view: EditorView,
readonly path: string,
) {
editors.set(view, this);
const schedule = () => this.schedule();
this.disposers.push(
useReviewBranch.subscribe(schedule),
useReviewModeSettings.subscribe(schedule),
useNewComments.subscribe(schedule),
useGhRepository.subscribe(schedule),
);
this.schedule();
}
get relativePath(): string | null {
return repositoryPath(this.path);
}
/** Recomputes on the next microtask (state changes come in bursts). */
schedule() {
if (this.scheduled || this.destroyed) return;
this.scheduled = true;
queueMicrotask(() => {
this.scheduled = false;
if (!this.destroyed) this.sync();
});
}
update(u: ViewUpdate) {
if (u.docChanged && this.shown) {
clearTimeout(this.diffTimer);
this.diffTimer = setTimeout(() => void this.computeLocal(), 150);
}
}
destroy() {
this.destroyed = true;
clearTimeout(this.diffTimer);
editors.delete(this.view);
this.disposers.forEach((d) => d());
this.stopData?.();
this.unmountToolbar();
}
private followData(n: number | null) {
if (this.dataFor === n) return;
this.stopData?.();
this.stopData = null;
this.dataFor = n;
if (n === null) return;
this.stopData = subscribePullRequestData(n, () => this.schedule());
void loadPullRequestData(n, "changes").catch(() => undefined);
}
private sync() {
const branch = useReviewBranch.getState();
const n = useGhRepository.getState().connection ? branch.number : null;
this.followData(n);
const rel = this.relativePath;
const files = n === null ? undefined : pullRequestData(n, "changes").value;
const file = (rel && files?.find((f) => f.filename === rel && f.status !== "removed")) || null;
this.number = file ? n : null;
this.file = file;
if (this.number === null) {
this.unmountToolbar();
this.hide();
return;
}
this.mountToolbar();
const settings = useReviewModeSettings.getState();
if (!settings.enabled || branch.updateRequired) {
this.hide();
return;
}
for (const key of ["details", "threads", "pendingReview"] as const)
void loadPullRequestData(this.number, key).catch(() => undefined);
const oid = pullRequestData(this.number, "details").value?.headRefOid;
if (!oid) return;
if (this.head?.oid !== oid) {
void this.loadHead(oid);
return;
}
if (this.head.text === null) return;
if (!this.local) {
void this.computeLocal();
return;
}
this.show();
}
private async loadHead(oid: string) {
this.head = { oid, text: null };
this.local = null;
try {
const text = await call(commands.vcsFileAt(this.path, oid));
if (this.head?.oid !== oid) return;
this.head = { oid, text: (text ?? "").replace(/\r\n?/g, "\n") };
} catch {
if (this.head?.oid === oid) this.head = null;
return;
}
this.schedule();
}
/** The document's changes against the review head (`trackDocumentDiffSync`). */
private async computeLocal() {
const head = this.head?.text;
if (head == null) return;
const seq = ++this.diffSeq;
const doc = this.view.state.doc.toString();
let ranges: Range[];
try {
ranges = head === doc ? [] : hunksToRanges(await commands.vcsDiffHunks(head, doc));
} catch {
return;
}
if (seq !== this.diffSeq || this.destroyed) return;
this.local = ranges;
this.schedule();
}
private mountToolbar() {
if (this.toolbar) {
this.toolbar.root.render(<ReviewModeToolbar editor={this} />);
return;
}
const el = document.createElement("div");
el.className = "cm-openidea-reviewMode";
// Before the inspection widget (`Constraints.FIRST`).
el.style.order = "-1";
editorCorner(this.view).appendChild(el);
const root = createRoot(el);
root.render(<ReviewModeToolbar editor={this} />);
this.toolbar = { el, root };
}
private unmountToolbar() {
const t = this.toolbar;
if (!t) return;
this.toolbar = null;
t.el.remove();
queueMicrotask(() => t.root.unmount());
}
private hide() {
if (!this.installed) return;
this.installed = false;
this.shown = false;
this.gutter = EMPTY_REVIEW_GUTTER;
this.view.dispatch({ effects: compartment.reconfigure([]) });
if (useEditorRegistry.getState().active?.view === this.view) setContext("githubReviewEditor", false);
}
/** The review head's lines in the document (`postReviewRanges`). */
private get ranges(): Range[] {
return this.local ?? [];
}
private threads(): GHPullRequestReviewThread[] {
if (this.number === null) return [];
const option = useReviewModeSettings.getState().threads;
return (pullRequestData(this.number, "threads").value ?? []).filter(
(t) => t.path === this.file?.filename && (option === "all" || !t.isResolved),
);
}
/** The visible threads with their document line. */
private placedThreads(): Array<{ thread: GHPullRequestReviewThread; line: number }> {
const out = [];
for (const thread of this.threads()) {
const r = editorThreadRange(thread);
if (r) out.push({ thread, line: lineToAfter(this.ranges, r.end) });
}
return out;
}
private show() {
const n = this.number;
const file = this.file;
if (n === null || !file) return;
if (!this.installed) {
this.installed = true;
this.view.dispatch({ effects: compartment.reconfigure(reviewExtensions(this)) });
}
this.shown = true;
if (useEditorRegistry.getState().active?.view === this.view) setContext("githubReviewEditor", true);
const ranges = this.ranges;
const hunks = hunksOf(file.patch);
const inlays: LineInlay[] = [];
const withComments = new Set<number>();
const withInlays = new Set<number>();
const withNew = new Set<number>();
const outlines: ReviewOutline[] = [];
for (const { thread, line } of this.placedThreads()) {
withComments.add(line);
if (this.hidden.has(thread.id)) continue;
withInlays.add(line);
inlays.push({ key: thread.id, line, content: <ThreadInlay number={n} threadId={thread.id} /> });
}
for (const c of newCommentsOf(n, file.filename, null)) {
const loc = c.location;
if (loc.side !== "right" || loc.startSide !== "right") continue;
const line = lineToAfter(ranges, loc.line);
const start = lineToAfter(ranges, loc.startLine);
withNew.add(line);
withInlays.add(line);
inlays.push({ key: c.id, line, content: <NewCommentInlay id={c.id} /> });
const suggestion = containsSuggestedChange(c.text);
outlines.push({
key: c.id,
range: { start: Math.min(start, line), end: Math.max(start, line) },
adjustable: !suggestion,
tooltip: suggestion
? tx("The range of this comment is not adjustable as it contains suggestion")
: tx("Drag to select several lines for commenting"),
});
}
const commentable = currentUserHasPermission("READ")
? shiftRanges(patchRanges(hunks), ranges).map((r) => ({ start: r.start2, end: r.end2 }))
: [];
this.gutter = {
linesWithComments: withComments,
linesWithNewComments: withNew,
linesWithInlays: withInlays,
commentable,
outlines,
};
this.view.dispatch({
effects: [
setLineInlays.of(inlays),
setReviewGutterState.of(this.gutter),
setReviewChanges.of(documentChanges(shiftRanges(patchChanges(hunks), ranges))),
],
});
}
// ---------------------------------------------------------------------------
// Gutter requests (document lines)
/** Document lines as review head lines; null when one was added locally. */
private toHead(range: LineRange): LineRange | null {
const start = lineFromAfter(this.ranges, range.start);
const end = lineFromAfter(this.ranges, range.end);
return start === null || end === null || start < 0 || end < 0 ? null : { start, end };
}
request(range: LineRange) {
const head = this.toHead(range);
if (this.number === null || !this.file || !head) return;
requestNewComment(this.number, this.file.filename, null, newCommentLocation("right", head.start, head.end));
}
cancelAt(line: number) {
const head = lineFromAfter(this.ranges, line);
if (this.number === null || !this.file || head === null) return;
const c = newCommentsOf(this.number, this.file.filename, null).find(
(x) => x.location.side === "right" && x.location.line === head,
);
if (c) void discardNewComment(c);
}
adjust(key: string, range: LineRange) {
const head = this.toHead(range);
if (head) updateNewComment(key, { location: newCommentLocation("right", head.start, head.end) });
}
/** Shows the threads of the line, or hides them when any is shown (`syncOrToggleAll`). */
toggleAt(line: number) {
const ids = this.placedThreads()
.filter((p) => p.line === line)
.map((p) => p.thread.id);
const anyShown = ids.some((id) => !this.hidden.has(id));
for (const id of ids) {
if (anyShown) this.hidden.add(id);
else this.hidden.delete(id);
}
this.show();
}
// ---------------------------------------------------------------------------
// Actions
private caretLine(): number {
const s = this.view.state;
return s.doc.lineAt(s.selection.main.head).number - 1;
}
/** "Add Review Comment" on the caret's line, "Add Multiline Review Comment" on the selected lines. */
addComment(multiline: boolean) {
const s = this.view.state;
const sel = s.selection.main;
const range = multiline
? { start: s.doc.lineAt(sel.from).number - 1, end: s.doc.lineAt(sel.to).number - 1 }
: { start: this.caretLine(), end: this.caretLine() };
if (
multiline
? range.start < range.end && canCreateComment(this.gutter, range)
: isLineCommentable(this.gutter, range.start)
)
this.request(range);
}
/** "Refresh Review Data": the pending review and the threads. */
refresh() {
if (this.number === null) return;
void reloadPullRequestData(this.number, "pendingReview").catch(() => undefined);
void reloadPullRequestData(this.number, "threads").catch(() => undefined);
}
/** "Show Review Diff": the pull request's diff of this file at the caret's line. */
showDiff() {
if (this.number === null || !this.file) return;
const line = this.shown ? lineFromAfter(this.ranges, this.caretLine(), true) : null;
void openPullRequestFileDiff(this.number, this.file.filename, line === null ? {} : { line, side: "right" });
}
/** Previous / Next Comment: the visible thread before / after the current one or the caret, in any file. */
async gotoComment(dir: 1 | -1): Promise<boolean> {
const n = this.number;
const file = this.file;
if (n === null || !file || !this.shown) return false;
const [files, threads] = await Promise.all([loadPullRequestData(n, "changes"), loadPullRequestData(n, "threads")]);
const option = useReviewModeSettings.getState().threads;
const order = threadOrder(
files.map((f) => ({ path: f.filename, hunks: hunksOf(f.patch) })),
threads,
);
const visible = (id: string) => {
const t = threads.find((x) => x.id === id);
return !!t && (option === "all" || !t.isResolved) && !!editorThreadRange(t);
};
const caret = this.caretLine();
const onLast = navigated?.number === n && navigated.path === file.filename && navigated.line === caret;
// `lineToUnified`: the left line doesn't matter, but at the start so the line's first thread is next.
const right = lineFromAfter(this.ranges, caret, true) ?? 0;
const from = onLast
? { id: navigated!.id }
: { file: files.findIndex((f) => f.filename === file.filename), left: -1, right };
const target = adjacentThread(order, from, dir, visible);
if (!target) return false;
if (target.path === file.filename) {
const line = lineToAfter(this.ranges, target.line);
navigated = { number: n, id: target.id, path: target.path, line };
this.hidden.delete(target.id);
this.show();
const pos = this.view.state.doc.line(Math.min(line + 1, this.view.state.doc.lines)).from;
this.view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }) });
this.view.focus();
} else {
// Another file's local changes are not known here: its head line is the best guess.
navigated = { number: n, id: target.id, path: target.path, line: target.line };
const root = currentRepoRoot();
if (root)
await maybeEditorService()?.openFile(`${root}/${target.path}`, {
selection: { line: target.line, col: 0 },
focus: true,
});
}
return true;
}
}
/** The editor contribution: every file editor gets the review when its file is in the pull request. */
export function reviewModeEditorExtension(ctx: EditorDocContext): Extension {
if (ctx.virtual || ctx.doc < 0) return [];
return [compartment.of([]), ViewPlugin.define((view) => new ReviewEditor(view, ctx.path))];
}
+157
Переглянути файл
@@ -0,0 +1,157 @@
/**
* Review mode (IntelliJ's `GHPROnCurrentBranchService` with `GHPRReviewInEditorController`):
* when the checked-out branch tracks the head of an open pull request of the connected
* repository,
*
* - the branch widget shows "#{n} on {branch}" (a warning when the local branch is out of
* sync) and its popup gets "Show Pull Request in the Tool Window", "Submit Review…",
* "Update to Enable Review Mode…" and the "Review Mode" toggle (`vcs/branchWidgetExtensions`);
* - editors of the pull request's files show its review (`editor.tsx`) with the "Review Mode"
* group at their top-right;
* - "Show Review Diff" opens the pull request's diff of the file.
*
* Provides the `showCurrentBranchPullRequest`, `updateReviewBranch`, `toggleReviewMode` and
* `isReviewModeOn` handlers behind the `github.pullRequest.showInToolWindow`,
* `.updateBranch` and `.reviewMode` actions.
*/
import { registerEditorContribution } from "@/editor/api";
import { provideBranchWidgetExtension } from "@/features/vcs/branchWidgetExtensions";
import type { PopupAction } from "@/features/vcs/branches";
import { useVcs } from "@/features/vcs/store";
import { registerActions } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { registerMenuGroup } from "@/workbench/contributions";
import { subscribePullRequestData } from "../data/pullRequest";
import { refreshConnection, useGhRepository } from "../data/repository";
import { provideGithubHandler } from "../toolwindow/handlers";
import { openPullRequest } from "../toolwindow/open";
import { activeReviewFile, reviewModeEditorExtension, trackActiveReviewEditor } from "./editor";
import { reloadReviewModeSettings, setReviewModeEnabled, useReviewModeSettings } from "./settings";
import { changesState, trackCurrentBranchPullRequest, updateReviewBranch, useReviewBranch } from "./state";
/** The branch widget's presentation and popup actions (`BranchPresenter`, `Github.Pull.Request.Branch.Popup.Actions`). */
function branchExtension(): () => void {
let dispose: (() => void) | null = null;
let stopData: (() => void) | null = null;
let dataFor: number | null = null;
const sync = () => {
const { number: n, branch, updateRequired } = useReviewBranch.getState();
if (dataFor !== n) {
stopData?.();
stopData = n === null ? null : subscribePullRequestData(n, sync);
dataFor = n;
}
if (n === null || !branch) {
dispose?.();
dispose = null;
return;
}
const text = tx`#${n} on ${branch}`;
const state = changesState(n);
const tooltip = updateRequired
? tx`Pull request #${n} exists for current branch ${branch}, but local branch is out of sync with remote`
: state === "loading"
? tx`Loading changes for pull request #${n}`
: state === "error"
? tx`Pull request #${n} exists for current branch ${branch}, but pull request details loading failed`
: tx`Pull request #${n} exists for current branch ${branch}`;
const actions: PopupAction[] = [
{
id: "github.pullRequest.showInToolWindow",
label: "Show Pull Request in the Tool Window",
icon: "toolwindow-pull-requests",
},
{ id: "github.pullRequest.submitReview", label: "Submit Review…", icon: "github-review", arg: n },
...(updateRequired
? [{ id: "github.pullRequest.updateBranch", label: "Update to Enable Review Mode…", icon: "github-warning" }]
: [
{
id: "github.pullRequest.reviewMode",
label: "Review Mode",
checked: useReviewModeSettings.getState().enabled,
},
]),
];
const next = provideBranchWidgetExtension({
id: "github.pullRequest",
presentation: { icon: updateRequired ? "github-warning" : "toolwindow-pull-requests", text, tooltip },
actions,
});
dispose?.();
dispose = next;
};
const unsubs = [useReviewBranch.subscribe(sync), useReviewModeSettings.subscribe(sync)];
sync();
return () => {
unsubs.forEach((u) => u());
stopData?.();
dispose?.();
};
}
/** IntelliJ connects on start (`loginIfPossible`): the current branch's pull request is looked up without the tool window. */
function connectEagerly(): () => void {
const tryConnect = () => {
const g = useGhRepository.getState();
if (useVcs.getState().available && useProject.getState().project && !g.availability && !g.checking)
void refreshConnection();
};
const unsubs = [
useVcs.subscribe((s, prev) => {
if (s.available !== prev.available) tryConnect();
}),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) {
reloadReviewModeSettings();
// The connection is reset by the feature's own subscription first.
queueMicrotask(tryConnect);
}
}),
];
tryConnect();
return () => unsubs.forEach((u) => u());
}
export function activateReviewMode(): () => void {
const disposers = [
trackCurrentBranchPullRequest(),
branchExtension(),
connectEagerly(),
trackActiveReviewEditor(),
registerEditorContribution({ id: "github.reviewMode", order: 58, extensions: reviewModeEditorExtension }),
useReviewBranch.subscribe((s, prev) => {
if (s.number === prev.number) return;
setContext("githubCurrentBranchPullRequest", s.number !== null);
}),
provideGithubHandler("showCurrentBranchPullRequest", () => {
const n = useReviewBranch.getState().number;
if (n !== null) void openPullRequest(n);
}),
provideGithubHandler("updateReviewBranch", () => void updateReviewBranch()),
provideGithubHandler("toggleReviewMode", () => setReviewModeEnabled(!useReviewModeSettings.getState().enabled)),
provideGithubHandler("isReviewModeOn", () => useReviewModeSettings.getState().enabled),
registerActions([
{
// Code.Review.Editor.Show.Diff
id: "github.pullRequest.reviewMode.showDiff",
title: "Show Review Diff",
category: "GitHub",
icon: "diff",
when: "projectOpen && githubConnected && githubCurrentBranchPullRequest",
run: () => activeReviewFile()?.showDiff(),
},
]),
registerMenuGroup({
id: "github.reviewMode",
menus: ["editor"],
order: 60,
items: () => (activeReviewFile() ? [{ kind: "action", action: "github.pullRequest.reviewMode.showDiff" }] : []),
}),
];
return () => {
disposers.reverse().forEach((d) => d());
setContext("githubCurrentBranchPullRequest", false);
};
}
+248
Переглянути файл
@@ -0,0 +1,248 @@
/**
* Pure logic of review mode (IntelliJ's `GHPRConnectedProjectViewModel.prOnCurrentBranch`,
* `GHPRReviewBranchStateSharedViewModel`, `GHPRReviewFileEditorModel`, collaboration-tools'
* `CodeReviewEditorDocumentUtil` and `ExcludingApproximateChangedRangesShifter`):
*
* - the remote branch the current branch tracks, from its git config, and the REST query of
* the open pull request with that head;
* - whether the local branch is in sync (the pull request's head is in its history);
* - line ranges between revisions: the review's changes (the patch without context), the
* local edits of the document against the review head, and lines and ranges carried from
* the review head to the document and back;
* - where a review thread sits in the editor (right side, current threads only).
*
* Lines are 0-based; ranges are half-open.
*/
import type { LineHunk } from "@/bindings";
import type { GHPullRequestReviewThread, GHRepositoryCoordinates } from "../api/types";
import type { PatchHunk } from "../diff/model";
import { parseGitHubRemote, remoteOfRepository } from "../data/model";
// ---------------------------------------------------------------------------
// The pull request of the current branch
/** What the current branch tracks (`branch.<name>.remote` / `.merge`). */
export interface TrackedBranch {
/** The repository the remote points to. */
repository: GHRepositoryCoordinates;
/** The branch name on the remote (`refs/heads/` stripped). */
branch: string | null;
/** `refs/pull/<n>/head` (gh checks out some pull requests so): the pull request itself. */
pullRequest: number | null;
}
/**
* The remote branch the current branch tracks (`findHostedRemoteBranchTrackedByCurrent`):
* `remote` is a remote's name or URL (gh sets URLs for forks), `merge` the remote ref.
* Null when it tracks nothing or the remote is not on GitHub. `connected` is recognised by
* the URL's path too, for a remote behind an SSH host alias.
*/
export function trackedBranch(
remote: string | null,
merge: string | null,
remotes: ReadonlyArray<{ name: string; url: string }>,
connected?: GHRepositoryCoordinates,
): TrackedBranch | null {
if (!remote || !merge || remote === ".") return null;
const url = remotes.find((r) => r.name === remote)?.url ?? remote;
const repository =
parseGitHubRemote(url) ?? (connected && remoteOfRepository([{ name: remote, url }], connected) ? connected : null);
if (!repository) return null;
const pr = /^refs\/pull\/(\d+)\/head$/.exec(merge);
if (pr) return { repository, branch: null, pullRequest: Number(pr[1]) };
const branch = merge.startsWith("refs/heads/") ? merge.slice("refs/heads/".length) : merge;
return branch ? { repository, branch, pullRequest: null } : null;
}
/**
* `GET /repos/{base}/pulls` for the open pull request whose head is `tracked`
* (`GHPRCreationService.findOpenPullRequest`: `head=<owner>:<branch>`, first match only).
*/
export function openPullRequestQuery(base: GHRepositoryCoordinates, tracked: TrackedBranch): string | null {
if (!tracked.branch) return null;
const head = encodeURIComponent(`${tracked.repository.owner}:${tracked.branch}`);
return `repos/${base.owner}/${base.name}/pulls?state=open&head=${head}&per_page=1`;
}
/**
* `updateRequired` (`isInCurrentHistory`): the pull request's head is not in the local
* history, i.e. it is not the merge base of itself and HEAD (null when git doesn't know it).
*/
export function isUpdateRequired(headRefOid: string, mergeBase: string | null): boolean {
return mergeBase?.toLowerCase() !== headRefOid.toLowerCase();
}
// ---------------------------------------------------------------------------
// Line ranges
/** A change between two texts: lines `[start1, end1)` of the first are `[start2, end2)` of the second. */
export interface Range {
start1: number;
end1: number;
start2: number;
end2: number;
}
/** The changes of a patch without its context lines (`withoutContext`): base → review head. */
export function patchChanges(hunks: readonly PatchHunk[]): Range[] {
const out: Range[] = [];
for (const h of hunks) {
let left = h.leftStart;
let right = h.rightStart;
let open: Range | null = null;
const close = () => {
if (open) out.push(open);
open = null;
};
for (const row of h.rows) {
const c = row[0];
if (c === "\\") continue;
if (c === "-" || c === "+") {
open ??= { start1: left, end1: left, start2: right, end2: right };
if (c === "-") open.end1 = ++left;
else open.end2 = ++right;
} else {
close();
left++;
right++;
}
}
close();
}
return out;
}
/** The commentable lines of a patch as ranges of both sides (`patch.ranges`: whole hunks). */
export function patchRanges(hunks: readonly PatchHunk[]): Range[] {
return hunks.map((h) => ({
start1: h.leftStart,
end1: h.leftStart + h.leftCount,
start2: h.rightStart,
end2: h.rightStart + h.rightCount,
}));
}
/** Line hunks of a text diff (`vcsDiffHunks(old, new)`) as ranges old → new. */
export function hunksToRanges(hunks: readonly Pick<LineHunk, "start" | "lines" | "oldStart" | "oldLines">[]): Range[] {
return hunks
.map((h) => ({ start1: h.oldStart, end1: h.oldStart + h.oldLines, start2: h.start, end2: h.start + h.lines }))
.sort((a, b) => a.start1 - b.start1);
}
/** `transferLineToAfter`: `line` of the first text in the second (a changed line goes to the change's last line). */
export function lineToAfter(ranges: readonly Range[], line: number): number {
let result = line;
for (const r of ranges) {
if (line >= r.start1 && line < r.end1) return Math.max(0, r.end2 - 1);
if (r.end1 > line) return result;
result += r.end2 - r.start2 - (r.end1 - r.start1);
}
return result;
}
/**
* `transferLineFromAfter`: `line` of the second text in the first; null when it was added or
* changed there, unless `approximate` (then the line after the change).
*/
export function lineFromAfter(ranges: readonly Range[], line: number, approximate = false): number | null {
let result = line;
for (const r of ranges) {
if (line < r.start2) return result;
if (line < r.end2) return approximate ? r.end1 : null;
result -= r.end2 - r.start2 - (r.end1 - r.start1);
}
return result;
}
/**
* `ExcludingApproximateChangedRangesShifter.shift`: `early` changes A → B and `later` ones
* B → C as changes A → C, leaving out the parts of `early` that `later` changed again.
*/
export function shiftRanges(early: readonly Range[], later: readonly Range[]): Range[] {
const result: Range[] = [];
let i = 0;
let shift = 0;
for (const e of early) {
let bStart = e.start2;
let fullyMapped = false;
while (i < later.length) {
const l = later[i]!;
const delta = l.end2 - l.start2 - (l.end1 - l.start1);
if (l.end1 <= e.start2) {
// Before.
shift += delta;
i++;
} else if (l.start1 >= e.end2) {
// After.
break;
} else if (l.start1 <= e.start2) {
if (l.end1 >= e.end2) {
// `early` inside `later`.
fullyMapped = true;
break;
}
// Overlapping its start.
bStart = l.end1;
shift += delta;
i++;
} else {
result.push({ start1: e.start1, end1: e.end1, start2: shift + bStart, end2: l.start2 });
if (l.end1 === e.end2) {
shift += delta;
i++;
fullyMapped = true;
break;
} else if (l.end1 < e.end2) {
// `later` inside `early`.
bStart = l.end1;
shift += delta;
i++;
} else {
// Overlapping its end.
fullyMapped = true;
break;
}
}
}
if (!fullyMapped && bStart <= e.end2)
result.push({ start1: e.start1, end1: e.end1, start2: shift + bStart, end2: shift + e.end2 });
}
return result;
}
export type ChangeKind = "added" | "modified" | "deleted";
/** A review change in the document, for the gutter (`asLst`). */
export interface DocumentChange {
kind: ChangeKind;
/** Document lines `[start, end)`; a deletion is empty at `start`. */
start: number;
end: number;
}
export function documentChanges(ranges: readonly Range[]): DocumentChange[] {
return ranges.map((r) => ({
kind: r.start1 === r.end1 ? "added" : r.start2 === r.end2 ? "deleted" : "modified",
start: r.start2,
end: r.end2,
}));
}
// ---------------------------------------------------------------------------
// Threads in the editor
/**
* The review-head lines `[start, end]` of `thread` in the editor (`mapToInEditorRange`):
* current line threads on the right side only (outdated ones and left-side ones are not
* in the head's text).
*/
export function editorThreadRange(thread: GHPullRequestReviewThread): { start: number; end: number } | null {
if (thread.subjectType === "FILE" || thread.isOutdated || thread.line == null) return null;
if (thread.diffSide !== "RIGHT" || (thread.startDiffSide ?? "RIGHT") !== "RIGHT") return null;
const end = thread.line - 1;
const start = (thread.startLine ?? thread.line) - 1;
return start > end ? null : { start, end };
}
/** "Show Unresolved Comments Only" / "Show All Comments"; "Disable Review Mode" turns it off instead. */
export type EditorThreadsOption = "unresolved" | "all";
+596
Переглянути файл
@@ -0,0 +1,596 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { LineHunk } from "@/bindings";
const ghApi = vi.fn();
const config: Record<string, string | null> = {};
let mergeBase: string | null = "head";
let diffHunks: (oldText: string, newText: string) => LineHunk[] = () => [];
const checkout = vi.fn();
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage = () => {};
},
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
return {
...real,
commands: {
...real.commands,
ghApi: (req: unknown) => ghApi(req),
vcsFileAt: () => ok(HEAD),
vcsDiffHunks: (a: string, b: string) => Promise.resolve(diffHunks(a, b)),
vcsConfigGet: (_repo: string | null, key: string) => ok(config[key] ?? null),
vcsRemotes: () =>
ok([
{ name: "origin", url: "[email protected]:o/r.git" },
{ name: "fork", url: "https://github.com/alice/r.git" },
]),
vcsMergeBase: () => ok(mergeBase),
ghPrCheckout: (...args: unknown[]) => {
checkout(...args);
return ok(null);
},
},
};
});
import { useEditorRegistry } from "@/editor/api";
import { useBranchWidgetExtensions } from "@/features/vcs/branchWidgetExtensions";
import { useVcs } from "@/features/vcs/store";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { setCurrentRepository } from "../api/client";
import type {
GHCommitFile,
GHPullRequest,
GHPullRequestReviewComment,
GHPullRequestReviewThread,
GHRepository,
GHUser,
} from "../api/types";
import { clearNewComments, useNewComments } from "../diff/newComments";
import { parsePatch } from "../diff/model";
import { clearPullRequests, setPullRequestData } from "../data/pullRequest";
import { useGhRepository } from "../data/repository";
import { activeReviewEditor, reviewModeEditorExtension } from "./editor";
import {
documentChanges,
editorThreadRange,
hunksToRanges,
isUpdateRequired,
lineFromAfter,
lineToAfter,
openPullRequestQuery,
patchChanges,
patchRanges,
shiftRanges,
trackedBranch,
} from "./model";
import { activateReviewMode } from ".";
import { setReviewModeEnabled, useReviewModeSettings } from "./settings";
import { refreshCurrentBranchPullRequest, updateReviewBranch, useReviewBranch } from "./state";
const N = 12;
const PATH = "src/a.ts";
const BASE = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
const HEAD = BASE.replace("line 3\n", "line three\n");
const PATCH = "@@ -1,6 +1,6 @@\n line 1\n line 2\n-line 3\n+line three\n line 4\n line 5\n line 6";
const me: GHUser = { __typename: "User", id: "U1", login: "me", url: "", avatarUrl: "", name: null };
const comment = (body: string): GHPullRequestReviewComment =>
({
id: `C-${body}`,
author: { __typename: "User", login: "alice", url: "", avatarUrl: "" },
body,
createdAt: "2026-10-01T10:00:00Z",
url: "",
state: "SUBMITTED",
commit: { oid: "head", abbreviatedOid: "head" },
originalCommit: { oid: "head", abbreviatedOid: "head" },
diffHunk: "",
pullRequestReview: { id: "R0" },
viewerCanDelete: false,
viewerCanUpdate: false,
viewerCanReact: false,
reactions: { nodes: [] },
}) as unknown as GHPullRequestReviewComment;
const thread = (id: string, line: number, over: Partial<GHPullRequestReviewThread> = {}) =>
({
id,
isResolved: false,
isOutdated: false,
path: PATH,
subjectType: "LINE",
diffSide: "RIGHT",
line,
originalLine: line,
startDiffSide: null,
startLine: null,
originalStartLine: null,
comments: { nodes: [comment(`body of ${id}`)] },
viewerCanReply: true,
viewerCanResolve: true,
viewerCanUnresolve: false,
...over,
}) as GHPullRequestReviewThread;
const file: GHCommitFile = {
sha: null,
filename: PATH,
status: "modified",
additions: 1,
deletions: 1,
changes: 2,
patch: PATCH,
previousFilename: null,
};
// ---------------------------------------------------------------------------
describe("model", () => {
const remotes = [
{ name: "origin", url: "[email protected]:o/r.git" },
{ name: "fork", url: "https://github.com/alice/r.git" },
];
it("finds the tracked remote branch from the branch's config", () => {
expect(trackedBranch("origin", "refs/heads/feature/x", remotes)).toEqual({
repository: { owner: "o", name: "r" },
branch: "feature/x",
pullRequest: null,
});
// gh sets a URL as the remote of a fork's branch.
expect(trackedBranch("https://github.com/bob/r.git", "refs/heads/fix", remotes)?.repository).toEqual({
owner: "bob",
name: "r",
});
expect(trackedBranch("origin", "refs/pull/7/head", remotes)).toMatchObject({ pullRequest: 7, branch: null });
expect(trackedBranch(null, null, remotes)).toBeNull();
expect(trackedBranch(".", "refs/heads/main", remotes)).toBeNull();
const alias = [{ name: "origin", url: "git@work-github:o/r.git" }];
expect(trackedBranch("origin", "refs/heads/x", alias)).toBeNull();
expect(trackedBranch("origin", "refs/heads/x", alias, { owner: "o", name: "r" })?.repository).toEqual({
owner: "o",
name: "r",
});
expect(trackedBranch("gitlab", "refs/heads/x", [{ name: "gitlab", url: "[email protected]:o/r.git" }])).toBeNull();
});
it("queries the open pull request with the head owner:branch", () => {
const tracked = trackedBranch("fork", "refs/heads/a b", remotes)!;
expect(openPullRequestQuery({ owner: "o", name: "r" }, tracked)).toBe(
"repos/o/r/pulls?state=open&head=alice%3Aa%20b&per_page=1",
);
});
it("requires an update when the head is not in the local history", () => {
expect(isUpdateRequired("abc", "abc")).toBe(false);
expect(isUpdateRequired("ABC", "abc")).toBe(false);
expect(isUpdateRequired("abc", "def")).toBe(true);
expect(isUpdateRequired("abc", null)).toBe(true);
});
it("takes the patch's changes without context and its commentable ranges", () => {
const hunks = parsePatch("@@ -1,5 +1,6 @@\n a\n-b\n+B\n+B2\n c\n-d\n e");
expect(patchChanges(hunks)).toEqual([
{ start1: 1, end1: 2, start2: 1, end2: 3 },
{ start1: 3, end1: 4, start2: 4, end2: 4 },
]);
expect(documentChanges(patchChanges(hunks)).map((c) => c.kind)).toEqual(["modified", "deleted"]);
expect(patchRanges(hunks)).toEqual([{ start1: 0, end1: 5, start2: 0, end2: 6 }]);
});
it("carries lines across local changes and back", () => {
// Two lines inserted at 2, line 6 changed into two.
const ranges = hunksToRanges([
{ start: 2, lines: 2, oldStart: 2, oldLines: 0 },
{ start: 8, lines: 2, oldStart: 6, oldLines: 1 },
]);
expect([0, 1, 2, 5, 6, 7].map((l) => lineToAfter(ranges, l))).toEqual([0, 1, 4, 7, 9, 10]);
expect(lineFromAfter(ranges, 4)).toBe(2);
expect(lineFromAfter(ranges, 2)).toBeNull();
expect(lineFromAfter(ranges, 2, true)).toBe(2);
expect(lineFromAfter(ranges, 10)).toBe(7);
});
it("shifts review ranges over local changes, leaving out what they changed again", () => {
const review = [
{ start1: 0, end1: 0, start2: 2, end2: 4 },
{ start1: 5, end1: 6, start2: 8, end2: 9 },
];
// One line inserted at the top; the second review change edited locally.
const local = [
{ start1: 0, end1: 0, start2: 0, end2: 1 },
{ start1: 8, end1: 9, start2: 9, end2: 11 },
];
expect(shiftRanges(review, local)).toEqual([{ start1: 0, end1: 0, start2: 3, end2: 5 }]);
// A local edit inside a review range splits it.
expect(
shiftRanges([{ start1: 0, end1: 10, start2: 0, end2: 10 }], [{ start1: 4, end1: 5, start2: 4, end2: 4 }]),
).toEqual([
{ start1: 0, end1: 10, start2: 0, end2: 4 },
{ start1: 0, end1: 10, start2: 4, end2: 9 },
]);
});
it("places current right-side threads only", () => {
expect(editorThreadRange(thread("a", 4))).toEqual({ start: 3, end: 3 });
expect(editorThreadRange(thread("a", 6, { startLine: 4, startDiffSide: "RIGHT" }))).toEqual({ start: 3, end: 5 });
expect(editorThreadRange(thread("a", 4, { diffSide: "LEFT" }))).toBeNull();
expect(editorThreadRange(thread("a", 4, { isOutdated: true }))).toBeNull();
expect(editorThreadRange(thread("a", 4, { subjectType: "FILE" }))).toBeNull();
});
});
// ---------------------------------------------------------------------------
function calls(op: RegExp) {
return ghApi.mock.calls
.map(([req]) => req as { endpoint: string; body?: string })
.filter((r) => op.test(r.endpoint === "graphql" ? (JSON.parse(r.body!) as { query: string }).query : r.endpoint))
.map((r) =>
r.endpoint === "graphql" ? (JSON.parse(r.body!) as { variables: Record<string, unknown> }).variables : r,
);
}
let restPulls: unknown = [{ number: N }];
function respond(req: {
endpoint: string;
body?: string;
}): { status: "ok"; data: string } | { status: "error"; error: unknown } {
if (req.endpoint.startsWith("repos/o/r/pulls?")) return { status: "ok", data: JSON.stringify(restPulls) };
const q = req.endpoint === "graphql" ? (JSON.parse(req.body!) as { query: string }).query : "";
if (/addPullRequestReview\s*\(/.test(q))
return {
status: "ok",
data: JSON.stringify({
data: {
addPullRequestReview: { pullRequestReview: { id: "PRR1", state: "PENDING", comments: { totalCount: 1 } } },
},
}),
};
if (/addPullRequestReviewThread\s*\(/.test(q))
return {
status: "ok",
data: JSON.stringify({ data: { addPullRequestReviewThread: { thread: thread("T-new", 4) } } }),
};
return { status: "error", error: { kind: "other", message: "not stubbed" } };
}
const notify = vi.fn();
let views: EditorView[] = [];
let deactivate: (() => void) | null = null;
beforeEach(() => {
localStorage.clear();
ghApi.mockReset();
ghApi.mockImplementation((req: { endpoint: string; body?: string }) => Promise.resolve(respond(req)));
checkout.mockReset();
notify.mockReset();
provideNotificationService({ notify } as unknown as NotificationService);
restPulls = [{ number: N }];
mergeBase = "head";
diffHunks = () => [];
for (const k of Object.keys(config)) delete config[k];
setCurrentRepository({ owner: "o", name: "r", id: "R1" });
useVcs.setState({
available: true,
root: "/p",
roots: ["/p"],
repo: "/p",
branch: { head: "feature", headCommit: "head", upstream: "origin/feature", ahead: 0, behind: 0, operation: null },
});
useGhRepository.setState({
connection: {
coordinates: { owner: "o", name: "r" },
repository: { id: "R1", viewerPermission: "READ" } as GHRepository,
currentUser: me,
},
});
useReviewModeSettings.setState({ enabled: true, threads: "unresolved" });
setPullRequestData(
N,
"details",
() => ({ id: "PR1", number: N, headRefOid: "head", baseRefOid: "base" }) as GHPullRequest,
);
setPullRequestData(N, "changes", () => [file]);
setPullRequestData(N, "pendingReview", () => null);
setPullRequestData(N, "threads", () => [thread("T1", 3), thread("T2", 5, { isResolved: true })]);
});
afterEach(async () => {
deactivate?.();
deactivate = null;
for (const v of views) v.destroy();
views = [];
cleanup();
// React roots of the inlays and the toolbar unmount in a microtask.
await new Promise((r) => setTimeout(r, 0));
clearNewComments();
clearPullRequests();
useEditorRegistry.setState({ active: null });
useReviewBranch.setState({ number: null, branch: null, updateRequired: false });
useGhRepository.setState({ connection: null });
useVcs.setState({ available: false, branch: null, repo: null, roots: [], root: null });
});
describe("the pull request of the current branch", () => {
it("finds the open pull request whose head the branch tracks and checks the sync state", async () => {
config["branch.feature.remote"] = "fork";
config["branch.feature.merge"] = "refs/heads/feature";
await refreshCurrentBranchPullRequest();
expect(useReviewBranch.getState()).toEqual({ number: N, branch: "feature", updateRequired: false });
expect(calls(/pulls\?/)).toEqual([
expect.objectContaining({
method: "GET",
endpoint: "repos/o/r/pulls?state=open&head=alice%3Afeature&per_page=1",
}),
]);
});
it("requires an update when the head commit is not in the local history", async () => {
config["branch.feature.remote"] = "origin";
config["branch.feature.merge"] = "refs/heads/feature";
mergeBase = null;
await refreshCurrentBranchPullRequest();
expect(useReviewBranch.getState()).toMatchObject({ number: N, updateRequired: true });
});
it("finds nothing without an upstream or an open pull request", async () => {
await refreshCurrentBranchPullRequest();
expect(useReviewBranch.getState().number).toBeNull();
config["branch.feature.remote"] = "origin";
config["branch.feature.merge"] = "refs/heads/feature";
restPulls = [];
await refreshCurrentBranchPullRequest();
expect(useReviewBranch.getState().number).toBeNull();
});
it("shows the pull request in the branch widget and adds its popup actions", async () => {
config["branch.feature.remote"] = "origin";
config["branch.feature.merge"] = "refs/heads/feature";
deactivate = activateReviewMode();
await waitFor(() => expect(useReviewBranch.getState().number).toBe(N));
const ext = () => useBranchWidgetExtensions.getState().extensions.get("github.pullRequest");
await waitFor(() => expect(ext()?.presentation?.text).toBe("#12 on feature"));
expect(ext()!.presentation!.tooltip).toBe("Pull request #12 exists for current branch feature");
expect(ext()!.actions!.map((a) => [a.label, a.checked])).toEqual([
["Show Pull Request in the Tool Window", undefined],
["Submit Review…", undefined],
["Review Mode", true],
]);
act(() => setReviewModeEnabled(false));
expect(ext()!.actions![2]!.checked).toBe(false);
act(() => useReviewBranch.setState({ updateRequired: true }));
expect(ext()!.presentation).toMatchObject({
icon: "github-warning",
tooltip: "Pull request #12 exists for current branch feature, but local branch is out of sync with remote",
});
expect(ext()!.actions!.map((a) => a.label)).toContain("Update to Enable Review Mode…");
deactivate();
deactivate = null;
expect(ext()).toBeUndefined();
});
it("Update to Enable Review Mode reloads the details, then checks out; a failed reload is reported", async () => {
useReviewBranch.setState({ number: N, branch: "feature", updateRequired: true });
// The reload fails (not stubbed).
await updateReviewBranch();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "Pull Request branch update failed" }),
);
expect(checkout).not.toHaveBeenCalled();
ghApi.mockImplementation((req: { endpoint: string; body?: string }) =>
Promise.resolve(
req.endpoint === "graphql" && /\.\.\.pullRequestInfo/.test((JSON.parse(req.body!) as { query: string }).query)
? {
status: "ok",
data: JSON.stringify({
data: { repository: { pullRequest: { id: "PR1", number: N, headRefOid: "head" } } },
}),
}
: respond(req),
),
);
await updateReviewBranch();
expect(checkout).toHaveBeenCalledTimes(1);
await waitFor(() => expect(useReviewBranch.getState().updateRequired).toBe(false));
});
});
// ---------------------------------------------------------------------------
function openEditor(doc: string) {
const ctx = { doc: 1, path: `/p/${PATH}`, language: "typescript", uiMode: null, readOnly: false };
const view = new EditorView({
state: EditorState.create({ doc, extensions: [reviewModeEditorExtension(ctx)] }),
parent: document.body,
});
views.push(view);
useEditorRegistry.setState({ active: { view, ctx } });
return view;
}
const inlay = (id: string) => document.querySelector<HTMLElement>(`[data-thread-inlay="${id}"]`);
const newCommentBox = () =>
waitFor(() => {
const el = document.querySelector<HTMLElement>("[data-new-comment]");
if (!el) throw new Error("no new comment inlay");
return el;
});
/** The text of the line an inlay is under (other inlays of the line come between). */
const lineOf = (el: HTMLElement) => {
let line = el.closest(".cm-lineInlay")!.previousElementSibling;
while (line?.classList.contains("cm-lineInlay")) line = line.previousElementSibling;
return line?.textContent;
};
describe("review in the editor", () => {
beforeEach(() => {
useReviewBranch.setState({ number: N, branch: "feature", updateRequired: false });
});
it("shows the unresolved threads under their lines, following local edits", async () => {
// Two lines added locally at the top.
diffHunks = (_a, b) =>
b.startsWith("new 1\nnew 2\n")
? [{ kind: "added", start: 0, lines: 2, oldStart: 0, oldLines: 0, oldText: "" }]
: [];
openEditor(`new 1\nnew 2\n${HEAD}`);
await waitFor(() => expect(inlay("T1")).not.toBeNull());
expect(lineOf(inlay("T1")!)).toBe("line three");
expect(inlay("T2")).toBeNull();
// The review's changed line is marked; the gutter offers comments on the hunk's lines.
expect(document.querySelectorAll(".cm-reviewGutter")).toHaveLength(1);
act(() => useReviewModeSettings.setState({ threads: "all" }));
await waitFor(() => expect(inlay("T2")).not.toBeNull());
expect(lineOf(inlay("T2")!)).toBe("line 5");
});
it("puts the Review Mode group in the editor's corner and hides the review when disabled", async () => {
openEditor(HEAD);
await waitFor(() => expect(inlay("T1")).not.toBeNull());
const button = document.querySelector<HTMLElement>(".cm-openidea-corner [data-review-mode]")!;
expect(button.dataset.reviewMode).toBe("on");
expect(button.dataset.tooltip).toBe(
"In Review Mode, you can see review comments and proposed changes right in the editor",
);
fireEvent.click(button);
const items = await screen.findAllByRole("menuitemcheckbox").catch(() => screen.findAllByRole("menuitem"));
expect(items.map((i) => i.textContent)).toEqual([
"Disable Review Mode",
"Show Unresolved Comments Only",
"Show All Comments",
]);
fireEvent.click(screen.getByText("Disable Review Mode"));
await waitFor(() => expect(inlay("T1")).toBeNull());
expect(useReviewModeSettings.getState().enabled).toBe(false);
expect(document.querySelector(".cm-reviewGutter")).toBeNull();
expect(document.querySelector<HTMLElement>("[data-review-mode]")!.dataset.reviewMode).toBe("off");
});
it("offers Update Branch to Enable Review Mode while out of sync", async () => {
useReviewBranch.setState({ updateRequired: true });
openEditor(HEAD);
const button = await waitFor(() => {
const b = document.querySelector<HTMLElement>("[data-review-mode]");
if (!b) throw new Error("no toolbar");
return b;
});
expect(button.dataset.reviewMode).toBe("outOfSync");
expect(inlay("T1")).toBeNull();
fireEvent.click(button);
expect(await screen.findByText("Update Branch to Enable Review Mode…")).toBeTruthy();
});
it("does nothing for files outside the pull request", async () => {
const ctx = { doc: 2, path: "/p/other.ts", language: "", uiMode: null, readOnly: false };
const view = new EditorView({
state: EditorState.create({ doc: HEAD, extensions: [reviewModeEditorExtension(ctx)] }),
parent: document.body,
});
views.push(view);
await new Promise((r) => setTimeout(r, 20));
expect(document.querySelector("[data-review-mode]")).toBeNull();
});
it("Add Single Comment posts a comment on the review head's line", async () => {
diffHunks = (_a, b) =>
b.startsWith("new 1\n") ? [{ kind: "added", start: 0, lines: 1, oldStart: 0, oldLines: 0, oldText: "" }] : [];
const view = openEditor(`new 1\n${HEAD}`);
await waitFor(() => expect(inlay("T1")).not.toBeNull());
// The caret on document line 4 ("line three", head line 2), a commentable line.
view.dispatch({ selection: { anchor: view.state.doc.line(4).from } });
act(() => activeReviewEditor()!.addComment(false));
const box = await newCommentBox();
expect(lineOf(box)).toBe("line three");
expect(
within(box)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual(["Add Single Comment", "Start Review"]);
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "why?" } });
fireEvent.click(within(box).getByRole("button", { name: "Add Single Comment" }));
await waitFor(() => expect(useNewComments.getState().comments).toHaveLength(0));
expect(calls(/addPullRequestReview\s*\(/)).toEqual([
{
pullRequestId: "PR1",
commitOid: "head",
event: "COMMENT",
threads: [{ body: "why?", path: PATH, line: 3, side: "RIGHT", startLine: null, startSide: null }],
},
]);
});
it("Start Review on several lines; lines added locally can't be commented", async () => {
diffHunks = (_a, b) =>
b.startsWith("new 1\n") ? [{ kind: "added", start: 0, lines: 1, oldStart: 0, oldLines: 0, oldText: "" }] : [];
openEditor(`new 1\n${HEAD}`);
await waitFor(() => expect(inlay("T1")).not.toBeNull());
const editor = activeReviewEditor()!;
act(() => editor.request({ start: 0, end: 0 }));
expect(useNewComments.getState().comments).toHaveLength(0);
act(() => editor.request({ start: 2, end: 4 }));
const box = await newCommentBox();
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "block" } });
fireEvent.click(within(box).getByRole("button", { name: "Start Review" }));
await waitFor(() => expect(calls(/addPullRequestReview\s*\(/)).toHaveLength(1));
expect(calls(/addPullRequestReview\s*\(/)[0]).toEqual({
pullRequestId: "PR1",
commitOid: "head",
threads: [{ body: "block", path: PATH, line: 4, side: "RIGHT", startLine: 2, startSide: "RIGHT" }],
});
});
it("Add Review Comment adds a thread to the pending review", async () => {
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 1 }));
openEditor(HEAD);
await waitFor(() => expect(inlay("T1")).not.toBeNull());
act(() => activeReviewEditor()!.request({ start: 0, end: 0 }));
const box = await newCommentBox();
expect(
within(box)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual(["Add Review Comment"]);
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "nit" } });
fireEvent.click(within(box).getByRole("button", { name: "Add Review Comment" }));
await waitFor(() =>
expect(calls(/addPullRequestReviewThread/)).toEqual([
{
pullRequestReviewId: "PRR0",
body: "nit",
path: PATH,
line: 1,
side: "RIGHT",
startLine: null,
startSide: null,
},
]),
);
});
it("Next Comment moves the caret to the next visible thread", async () => {
useReviewModeSettings.setState({ threads: "all" });
const view = openEditor(HEAD);
await waitFor(() => expect(inlay("T2")).not.toBeNull());
const editor = activeReviewEditor()!;
await act(() => editor.gotoComment(1));
expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(3);
await act(() => editor.gotoComment(1));
expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(5);
expect(await editor.gotoComment(1)).toBe(false);
});
});
+52
Переглянути файл
@@ -0,0 +1,52 @@
/**
* Review mode settings: the "Review Mode" toggle, persisted per project in local storage
* (`GithubPullRequestsProjectUISettings.editorReviewEnabled`, on by default), and the
* threads shown while it is on ("Show Unresolved Comments Only", the default, or "Show All
* Comments"; kept for the session like IntelliJ's view model).
*/
import { create } from "zustand";
import { useProject } from "@/platform/project";
import type { EditorThreadsOption } from "./model";
interface ReviewModeSettings {
enabled: boolean;
threads: EditorThreadsOption;
}
const key = (root: string) => `openidea.github.reviewMode.v1:${root}`;
const projectRoot = () => useProject.getState().project?.root ?? null;
function loadEnabled(root: string | null): boolean {
if (!root) return true;
try {
return globalThis.localStorage?.getItem(key(root)) !== "off";
} catch {
return true;
}
}
export const useReviewModeSettings = create<ReviewModeSettings>(() => ({
enabled: loadEnabled(projectRoot()),
threads: "unresolved",
}));
/** Turns review mode on or off for the project. */
export function setReviewModeEnabled(enabled: boolean) {
useReviewModeSettings.setState({ enabled });
const root = projectRoot();
if (!root) return;
try {
globalThis.localStorage?.setItem(key(root), enabled ? "on" : "off");
} catch {
/* storage unavailable */
}
}
export function setEditorThreadsOption(threads: EditorThreadsOption) {
useReviewModeSettings.setState({ threads });
}
/** Another project: its own toggle, the default threads option. */
export function reloadReviewModeSettings() {
useReviewModeSettings.setState({ enabled: loadEnabled(projectRoot()), threads: "unresolved" });
}
+165
Переглянути файл
@@ -0,0 +1,165 @@
/**
* The pull request of the checked-out branch (IntelliJ's `prOnCurrentBranch` with
* `GHPRReviewBranchStateSharedViewModel`): the open pull request whose head is the remote
* branch the current branch tracks, looked up again when the branch, its upstream or the
* repository changes; and whether the local branch is out of sync with it (`updateRequired`:
* its head commit is not in the local history), checked again when HEAD or the pull
* request's head moves.
*
* "Update to Enable Review Mode…" reloads the details, then fetches and checks out the
* branch (`gh pr checkout`).
*/
import { create } from "zustand";
import { currentRepoRoot, useVcs } from "@/features/vcs/store";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { notifications } from "@/workbench/contributions";
import { errorMessage, rest } from "../api/client";
import { checkoutPullRequest } from "../data/local";
import {
loadPullRequestData,
pullRequestData,
reloadPullRequestData,
subscribePullRequestData,
} from "../data/pullRequest";
import { useGhRepository } from "../data/repository";
import { isUpdateRequired, openPullRequestQuery, trackedBranch } from "./model";
export interface ReviewBranchState {
/** The open pull request whose head the current branch tracks. */
number: number | null;
/** The local branch. */
branch: string | null;
/** The pull request's head is not in the local history. */
updateRequired: boolean;
}
export const useReviewBranch = create<ReviewBranchState>(() => ({ number: null, branch: null, updateRequired: false }));
let lookupSeq = 0;
/** The open pull request for the current branch of the selected repository, or null. */
async function findPullRequestForCurrentBranch(): Promise<{ number: number; branch: string } | null> {
const connection = useGhRepository.getState().connection;
const head = useVcs.getState().branch?.head;
if (!connection || !head) return null;
const repo = currentRepoRoot();
const [remote, merge, remotes] = await Promise.all([
call(commands.vcsConfigGet(repo, `branch.${head}.remote`)),
call(commands.vcsConfigGet(repo, `branch.${head}.merge`)),
call(commands.vcsRemotes(repo)),
]);
const tracked = trackedBranch(remote, merge, remotes, connection.coordinates);
if (!tracked) return null;
if (tracked.pullRequest !== null) return { number: tracked.pullRequest, branch: head };
const query = openPullRequestQuery(connection.coordinates, tracked);
if (!query) return null;
const found = await rest<Array<{ number: number }> | null>("GET", query);
const number = found?.[0]?.number;
return typeof number === "number" ? { number, branch: head } : null;
}
/** Looks up the pull request of the current branch again (`prOnCurrentBranch`). */
export async function refreshCurrentBranchPullRequest(): Promise<void> {
const seq = ++lookupSeq;
let found: { number: number; branch: string } | null = null;
try {
found = await findPullRequestForCurrentBranch();
} catch (e) {
console.warn("Could not lookup a pull request for current branch", e);
}
if (seq !== lookupSeq) return;
const s = useReviewBranch.getState();
if (found?.number === s.number && found?.branch === s.branch) return;
useReviewBranch.setState({ number: found?.number ?? null, branch: found?.branch ?? null, updateRequired: false });
if (found) {
void loadPullRequestData(found.number, "details").catch(() => undefined);
void loadPullRequestData(found.number, "changes").catch(() => undefined);
}
await refreshSyncState();
}
let syncSeq = 0;
/** Checks again whether the pull request's head is in the local history. */
export async function refreshSyncState(): Promise<void> {
const seq = ++syncSeq;
const n = useReviewBranch.getState().number;
if (n === null) return;
let updateRequired = false;
try {
const pr = await loadPullRequestData(n, "details");
const base = await call(commands.vcsMergeBase(currentRepoRoot(), pr.headRefOid, "HEAD")).catch(() => null);
updateRequired = isUpdateRequired(pr.headRefOid, base);
} catch {
// Details not loaded: nothing to compare with (IntelliJ keeps `false`).
}
if (seq === syncSeq && useReviewBranch.getState().number === n) useReviewBranch.setState({ updateRequired });
}
/** "Update to Enable Review Mode…": reloads the details, then fetches and checks out the branch. */
export async function updateReviewBranch(): Promise<void> {
const n = useReviewBranch.getState().number;
if (n === null) return;
try {
await reloadPullRequestData(n, "details");
} catch (e) {
notifications().notify({
group: "vcs",
level: "error",
title: tx("Pull Request branch update failed"),
body: errorMessage(e),
key: "github-review-branch-update",
});
return;
}
if (await checkoutPullRequest(n)) await refreshSyncState();
}
/** Whether the changes of the pull request are loaded, loading or failed (the widget's tooltip). */
export function changesState(n: number): "loading" | "error" | "loaded" {
const s = pullRequestData(n, "changes");
return s.value !== undefined ? "loaded" : s.error ? "error" : "loading";
}
/** Follows the branch, the repository and the pull request's head; returns the disposer. */
export function trackCurrentBranchPullRequest(): () => void {
let stopData: (() => void) | null = null;
let lastHead: string | undefined;
const followData = (n: number | null) => {
stopData?.();
stopData = null;
lastHead = undefined;
if (n === null) return;
stopData = subscribePullRequestData(n, () => {
const head = pullRequestData(n, "details").value?.headRefOid;
if (head && head !== lastHead) {
lastHead = head;
void refreshSyncState();
}
});
};
const disposers = [
useVcs.subscribe((s, prev) => {
const a = s.branch;
const b = prev.branch;
if (s.repo !== prev.repo || a?.head !== b?.head || a?.upstream !== b?.upstream)
void refreshCurrentBranchPullRequest();
else if (a?.headCommit !== b?.headCommit) void refreshSyncState();
}),
useGhRepository.subscribe((s, prev) => {
if (s.connection !== prev.connection) void refreshCurrentBranchPullRequest();
}),
useReviewBranch.subscribe((s, prev) => {
if (s.number !== prev.number) followData(s.number);
}),
() => followData(null),
];
void refreshCurrentBranchPullRequest();
return () => {
lookupSeq++;
syncSeq++;
disposers.reverse().forEach((d) => d());
useReviewBranch.setState({ number: null, branch: null, updateRequired: false });
};
}