feat(github): pull request tool window, timeline and review flow (#477)

Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication.

- List with search, quick filters and paging; a tab per pull request and a New Pull Request tab.
- Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen.
- Timeline editor: comments, review threads, reactions and suggested changes.
- Review in the diff viewer and, for the checked-out branch, in the editor (review mode).
- New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands.
- CSP: img-src allows https://avatars.githubusercontent.com for avatars.

Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane.

Co-authored-by: imacross <30753807+imacross@users.noreply.github.com>
This commit is contained in:
imacross
2026-10-31 07:50:43 +08:00
committed by GitHub
parent 696377acc2
commit 059fd8a69b
192 changed files with 23668 additions and 2076 deletions
@@ -0,0 +1,20 @@
/**
* Settings › Version Control › GitHub (`GithubSettingsConfigurable`); accounts are gh's, so
* only the pull request option is here. Applies at once, like the Log page.
*/
import { Checkbox } from "@/ui";
import { setReviewDiffSettings, useReviewDiffSettings } from "./settings";
import styles from "./ReviewDiff.module.css";
export function GithubSettingsPage() {
const autoMarkViewed = useReviewDiffSettings((s) => s.autoMarkViewed);
return (
<div className={styles.page}>
<Checkbox
checked={autoMarkViewed}
onChange={(v) => setReviewDiffSettings({ autoMarkViewed: v })}
label="Automatically mark opened files as viewed"
/>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* The review inlays of a diff (`GHPRReviewEditorComponentsFactory`), in the editor inlay
* panel (`CodeReviewCommentUIUtil.createEditorInlayPanel`: rounded, the editor background):
*
* - a review thread, the timeline's compact `ThreadView` (more than 3 comments: the first,
* "… {n} more replies" and the last; Reply / Resolve);
* - a new comment: the current user's avatar and the comment field; Escape closes it, asking
* first when it has text ("Discard Comment"); its buttons follow the pending review.
*/
import { tx } from "@/platform/i18n";
import { ask } from "@/workbench/messages";
import { usePullRequestData } from "../data/pullRequest";
import { CommentEditor, submitShortcut, ThreadView } from "../timeline/comments";
import { SUBMIT_ACTION_TEXT, submitActions } from "./model";
import { removeNewComment, submitNewComment, updateNewComment, useNewComments, type NewComment } from "./newComments";
import { useReviewDiffSettings } from "./settings";
import styles from "./ReviewDiff.module.css";
export function ThreadInlay({ number, threadId }: { number: number; threadId: string }) {
const thread = usePullRequestData(number, "threads").value?.find((t) => t.id === threadId);
if (!thread) return null;
return (
<div className={styles.inlay} data-thread-inlay={threadId}>
<ThreadView number={number} thread={thread} variant="compact" />
</div>
);
}
/** Closes a new comment; with text, after "Are you sure you want to discard the unsaved comment?". */
export async function discardNewComment(c: NewComment): Promise<void> {
const current = useNewComments.getState().comments.find((x) => x.id === c.id) ?? c;
if (current.text.trim()) {
// `MessageDialogBuilder.yesNo`.
const r = await ask({
title: "Discard Comment",
body: tx("Are you sure you want to discard the unsaved comment?"),
buttons: [
{ id: "yes", label: "Yes", primary: true },
{ id: "no", label: "No" },
],
cancel: false,
});
if (r.button !== "yes") return;
}
removeNewComment(c.id);
}
export function NewCommentInlay({ id }: { id: string }) {
const comment = useNewComments((s) => s.comments.find((c) => c.id === id));
const pending = usePullRequestData(comment?.number ?? 0, "pendingReview");
const preferred = useReviewDiffSettings((s) => s.reviewCommentsPreferred);
if (!comment) return null;
// Until the pending review is known there is nothing to submit with (`submitActions` is empty).
const actions = pending.value === undefined ? [] : submitActions(!!pending.value, preferred);
const [primary, ...secondary] = actions;
const textOf = () => useNewComments.getState().comments.find((c) => c.id === id)?.text ?? "";
return (
<div className={styles.inlay} data-new-comment={id}>
<div className={styles.newComment}>
<CommentEditor
initialText={comment.text}
submitLabel={primary ? SUBMIT_ACTION_TEXT[primary] : "Add Review Comment"}
submitHint={primary ? tx`${submitShortcut()} to comment` : ""}
onSubmit={(text) => (primary ? submitNewComment(comment, primary, text) : Promise.resolve())}
onTextChange={(text) => updateNewComment(id, { text })}
onCancel={() => void discardNewComment(comment)}
noCancelButton
extraActions={secondary.map((a) => ({
label: SUBMIT_ACTION_TEXT[a],
disabled: !comment.text.trim(),
onClick: () => submitNewComment(comment, a, textOf()),
}))}
showAvatar
variant="compact"
autoFocus
focusRequest={comment.focus}
/>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
/* The editor inlay panel (`CodeReviewCommentUIUtil.createEditorInlayPanel`): 10px arc, the
tear line colour as border, the editor background; content at most 42em wide, and never
wider than the pane (the scroller is its size container; 96px: gutters and margins). */
.inlay {
box-sizing: border-box;
max-width: min(calc(42em + 60px), calc(100cqw - 96px));
margin: 4px 12px 4px 4px;
padding: 6px 0;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-editor);
font-family: var(--font-ui);
font-size: var(--fs-ui);
white-space: normal;
cursor: auto;
}
.newComment {
padding: 4px 10px;
}
.sep {
width: 1px;
height: 14px;
margin: 0 4px;
background: var(--border-strong);
}
.page {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}
@@ -0,0 +1,51 @@
/**
* The review actions of a pull request's diff toolbar (`GHPRDiffService` `CONTEXT_ACTIONS`):
* Refresh Review Data, the Submit Review button ("Submit Review…" / "Submit Review ({n})…",
* the details panel's popup through the `submitReview` handler), Previous / Next Comment.
*/
import { useContextKeys } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { Button, IconButton } from "@/ui";
import { usePullRequestData } from "../data/pullRequest";
import { githubHandler, handlerContextKey } from "../toolwindow/handlers";
import type { ReviewDiff } from "./reviewDiff";
import styles from "./ReviewDiff.module.css";
/** `{0, choice, 0#Submit Review…|1#Submit Review ({0})…}`. */
const submitReviewText = (pending: number) => (pending > 0 ? tx`Submit Review (${pending})…` : tx("Submit Review…"));
export function ReviewToolbar({ diff }: { diff: ReviewDiff }) {
const pending = usePullRequestData(diff.number, "pendingReview");
const threads = usePullRequestData(diff.number, "threads");
const canSubmit = useContextKeys((k) => !!k[handlerContextKey("submitReview")]);
const loading = pending.loading || threads.loading;
return (
<>
<span className={styles.sep} />
<IconButton
icon="refresh"
title="Refresh Review Data"
size="small"
disabled={loading}
onClick={() => diff.refresh()}
/>
<Button size="small" disabled={!canSubmit} onClick={() => githubHandler("submitReview")?.(diff.number)}>
{submitReviewText(pending.value?.commentsCount ?? 0)}
</Button>
<IconButton
icon="arrow-up"
title="Previous Comment"
size="small"
disabled={!diff.canNavigate}
onClick={() => void diff.gotoComment(-1)}
/>
<IconButton
icon="arrow-down"
title="Next Comment"
size="small"
disabled={!diff.canNavigate}
onClick={() => void diff.gotoComment(1)}
/>
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Review in the diff viewer (IntelliJ's `GHPRReviewDiffExtension`): pull request diffs
* (opened by `openPullRequestFileDiff` / the details panel's commit diffs, context
* `PULL_REQUEST_DIFF`) get their review threads as inlays, the gutter "+" with single- and
* multi-line comments, Start Review / Add Single Comment, the review toolbar and the viewed
* toggle, through the vcs diff viewer provider point. Also registers the editor actions and
* Settings › Version Control › GitHub. The editor actions also serve review mode's editors.
*/
import { registerDiffViewerProvider } from "@/features/vcs/diffViewerProviders";
import { registerActions } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { lazyComponent } from "@/ui";
import { registerSettingsPage } from "@/workbench/contributions";
import { useGhRepository } from "../data/repository";
import { activeReviewEditor } from "../reviewMode/editor";
import { clearNewComments } from "./newComments";
import { activeReviewDiff, createReviewDiff } from "./reviewDiff";
/** The editor actions apply to the focused review diff, or to the review in the active editor (review mode). */
const WHEN = "projectOpen && githubConnected && (githubReviewDiff || githubReviewEditor)";
function reviewTarget() {
const diff = activeReviewDiff();
return (getContext().diffEditorFocus && diff) || activeReviewEditor() || diff;
}
/** "Mark File as Viewed" / "Mark File as Not Viewed" in the focused review diff; false when none is focused. */
export function toggleReviewDiffViewed(): boolean {
const diff = activeReviewDiff();
if (!diff) return false;
diff.toggleViewed();
return true;
}
export function activateReviewDiff(): () => void {
const disposers = [
registerDiffViewerProvider({ id: "github.review", create: createReviewDiff }),
registerActions([
{
// Code.Review.Editor.New.Comment
id: "github.pullRequest.diff.addComment",
title: "Add Review Comment",
description: "Create a new code review comment on the line under the caret",
category: "GitHub",
when: WHEN,
run: () => reviewTarget()?.addComment(false),
},
{
id: "github.pullRequest.diff.addMultilineComment",
title: "Add Multiline Review Comment",
description: "Create a new code review comment on the selected lines",
category: "GitHub",
when: WHEN,
run: () => reviewTarget()?.addComment(true),
},
{
// CodeReview.PreviousComment
id: "github.pullRequest.diff.previousComment",
title: "Previous Comment",
description: "Go to previous visible comment",
category: "GitHub",
icon: "arrow-up",
when: WHEN,
run: () => void reviewTarget()?.gotoComment(-1),
},
{
// CodeReview.NextComment
id: "github.pullRequest.diff.nextComment",
title: "Next Comment",
description: "Go to next visible comment",
category: "GitHub",
icon: "arrow-down",
when: WHEN,
run: () => void reviewTarget()?.gotoComment(1),
},
{
id: "github.pullRequest.diff.refreshReview",
title: "Refresh Review Data",
category: "GitHub",
icon: "refresh",
when: WHEN,
run: () => reviewTarget()?.refresh(),
},
]),
registerSettingsPage({
id: "vcs.github",
title: "GitHub",
order: 9,
keywords: ["pull request", "viewed", "review"],
component: lazyComponent(() => import("./GithubSettingsPage").then((m) => m.GithubSettingsPage)),
}),
useGhRepository.subscribe((s, prev) => {
const a = s.connection?.coordinates;
const b = prev.connection?.coordinates;
if (a?.owner !== b?.owner || a?.name !== b?.name) clearNewComments();
}),
];
return () => disposers.reverse().forEach((d) => d());
}
+226
View File
@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import type { GHPullRequestReviewComment, GHPullRequestReviewThread } from "../api/types";
import {
adjacentThread,
commentableRanges,
diffPosition,
draftThread,
isThreadVisible,
mapAcrossPatch,
newCommentLocation,
parsePatch,
submitActions,
threadLocation,
threadOrder,
} from "./model";
import { parseReviewDiffSettings } from "./settings";
// a.ts: line 2 changed, line 4 added; 20 lines later one removed.
const PATCH = [
"@@ -1,4 +1,5 @@",
" one",
"-two",
"+TWO",
" three",
"+three and a half",
" four",
"@@ -20,3 +21,2 @@ function x() {",
" twenty",
"-twenty-one",
" twenty-two",
].join("\n");
const comment = (over: Partial<GHPullRequestReviewComment> = {}): GHPullRequestReviewComment =>
({
id: "C1",
author: null,
body: "",
createdAt: "2026-10-01T10:00:00Z",
url: "",
state: "SUBMITTED",
commit: { oid: "head", abbreviatedOid: "head" },
originalCommit: { oid: "c1", abbreviatedOid: "c1" },
diffHunk: "",
pullRequestReview: null,
viewerCanDelete: false,
viewerCanUpdate: false,
viewerCanReact: false,
reactions: { nodes: [] },
...over,
}) as GHPullRequestReviewComment;
const thread = (over: Partial<GHPullRequestReviewThread> = {}, c = comment()): GHPullRequestReviewThread => ({
id: "T1",
isResolved: false,
isOutdated: false,
path: "a.ts",
subjectType: "LINE",
diffSide: "RIGHT",
line: 3,
originalLine: 2,
startDiffSide: null,
startLine: null,
originalStartLine: null,
comments: { nodes: [c] },
viewerCanReply: true,
viewerCanResolve: true,
viewerCanUnresolve: false,
...over,
});
describe("patches", () => {
it("parses hunks with 0-based starts", () => {
const hunks = parsePatch(PATCH + "\n");
expect(hunks.map(({ rows: _r, ...h }) => h)).toEqual([
{ leftStart: 0, leftCount: 4, rightStart: 0, rightCount: 5 },
{ leftStart: 19, leftCount: 3, rightStart: 20, rightCount: 2 },
]);
expect(hunks[1]!.rows).toEqual([" twenty", "-twenty-one", " twenty-two"]);
expect(parsePatch(null)).toEqual([]);
});
it("takes every hunk line of a side as commentable (`patch.ranges`)", () => {
expect(commentableRanges(parsePatch(PATCH))).toEqual({
left: [
{ start: 0, end: 4 },
{ start: 19, end: 22 },
],
right: [
{ start: 0, end: 5 },
{ start: 20, end: 22 },
],
});
// A new file has no left side.
expect(commentableRanges(parsePatch("@@ -0,0 +1,2 @@\n+a\n+b")).left).toEqual([]);
});
it("maps lines across the patch", () => {
const hunks = parsePatch(PATCH);
expect(mapAcrossPatch(hunks, "left", 0)).toBe(0);
expect(mapAcrossPatch(hunks, "left", 2)).toBe(2); // "three"
expect(mapAcrossPatch(hunks, "left", 3)).toBe(4); // "four"
expect(mapAcrossPatch(hunks, "right", 3)).toBe(3); // the added line: before "four"
expect(mapAcrossPatch(hunks, "left", 10)).toBe(11); // between the hunks: +1
expect(mapAcrossPatch(hunks, "left", 21)).toBe(21); // "twenty-two"
expect(mapAcrossPatch(hunks, "left", 30)).toBe(30); // after: +1 -1
expect(mapAcrossPatch(hunks, "right", 30)).toBe(30);
});
it("finds GitHub's diff position of a line", () => {
expect(diffPosition(PATCH, "right", 0)).toBe(1);
expect(diffPosition(PATCH, "left", 1)).toBe(2);
expect(diffPosition(PATCH, "right", 1)).toBe(3);
expect(diffPosition(PATCH, "right", 3)).toBe(5);
// The second header counts as a row.
expect(diffPosition(PATCH, "left", 20)).toBe(9);
expect(diffPosition(PATCH, "right", 21)).toBe(10);
expect(diffPosition(PATCH, "right", 10)).toBeNull();
});
});
describe("threads in a diff", () => {
it("places current threads of the changes at their lines", () => {
expect(threadLocation(thread(), { commit: null })).toEqual({
side: "right",
line: 2,
startSide: "right",
startLine: 2,
});
expect(
threadLocation(thread({ diffSide: "LEFT", line: 5, startLine: 3, startDiffSide: "LEFT" }), { commit: null }),
).toEqual({ side: "left", line: 4, startSide: "left", startLine: 2 });
// Outdated and file threads have no line.
expect(threadLocation(thread({ line: null, isOutdated: true }), { commit: null })).toBeNull();
expect(threadLocation(thread({ subjectType: "FILE" }), { commit: null })).toBeNull();
});
it("places right-side threads of a commit's diff at their original lines", () => {
expect(threadLocation(thread(), { commit: "c1" })).toEqual({
side: "right",
line: 1,
startSide: "right",
startLine: 1,
});
expect(threadLocation(thread(), { commit: "head" })?.line).toBe(2);
expect(threadLocation(thread(), { commit: "other" })).toBeNull();
expect(threadLocation(thread({ diffSide: "LEFT" }), { commit: "c1" })).toBeNull();
});
it("filters by Show Review Threads", () => {
const resolved = { isResolved: true };
expect(isThreadVisible(resolved, "all")).toBe(true);
expect(isThreadVisible(resolved, "unresolved")).toBe(false);
expect(isThreadVisible({ isResolved: false }, "unresolved")).toBe(true);
expect(isThreadVisible({ isResolved: false }, "none")).toBe(false);
});
});
describe("new comments", () => {
it("builds the GitHub input of single- and multi-line comments", () => {
expect(draftThread("a.ts", newCommentLocation("right", 4, 4), "hi")).toEqual({
body: "hi",
path: "a.ts",
line: 5,
side: "RIGHT",
startLine: null,
startSide: null,
});
expect(draftThread("a.ts", newCommentLocation("left", 6, 2), "x")).toMatchObject({
line: 7,
side: "LEFT",
startLine: 3,
startSide: "LEFT",
});
});
it("offers Add Review Comment with a pending review, else the preferred of Start Review / Add Single Comment", () => {
expect(submitActions(true, false)).toEqual(["reviewComment"]);
expect(submitActions(false, true)).toEqual(["startReview", "singleComment"]);
expect(submitActions(false, false)).toEqual(["singleComment", "startReview"]);
});
it("parses the stored settings with IntelliJ's defaults", () => {
expect(parseReviewDiffSettings(null)).toEqual({
threadsView: "unresolved",
reviewCommentsPreferred: true,
autoMarkViewed: false,
});
expect(parseReviewDiffSettings({ threadsView: "none", autoMarkViewed: true, reviewCommentsPreferred: 1 })).toEqual({
threadsView: "none",
reviewCommentsPreferred: true,
autoMarkViewed: true,
});
});
});
describe("Previous / Next Comment", () => {
const files = [
{ path: "a.ts", hunks: parsePatch(PATCH) },
{ path: "b.ts", hunks: [] },
];
const threads = [
thread({ id: "b1", path: "b.ts", line: 1 }),
thread({ id: "a-right", line: 5 }),
thread({ id: "a-left", diffSide: "LEFT", line: 2 }),
thread({ id: "a-first", line: 1 }),
thread({ id: "outdated", line: null }),
thread({ id: "elsewhere", path: "c.ts" }),
];
const order = threadOrder(files, threads);
it("orders threads by file, right line, left line", () => {
expect(order.map((p) => p.id)).toEqual(["a-first", "a-left", "a-right", "b1"]);
expect(order[1]).toMatchObject({ side: "left", line: 1, left: 1, right: 1 });
});
it("moves from a thread or from the caret, skipping hidden threads", () => {
expect(adjacentThread(order, { id: "a-first" }, 1)?.id).toBe("a-left");
expect(adjacentThread(order, { id: "a-first" }, -1)).toBeNull();
expect(adjacentThread(order, { id: "a-left" }, 1, (id) => id !== "a-right")?.id).toBe("b1");
// The caret's line counts: the thread on it is the next and the previous one.
expect(adjacentThread(order, { file: 0, left: 3, right: 4 }, 1)?.id).toBe("a-right");
expect(adjacentThread(order, { file: 0, left: 3, right: 4 }, -1)?.id).toBe("a-right");
expect(adjacentThread(order, { file: 0, left: 3, right: 3 }, -1)?.id).toBe("a-left");
expect(adjacentThread(order, { file: 1, left: 9, right: 9 }, 1)).toBeNull();
});
});
+317
View File
@@ -0,0 +1,317 @@
/**
* Pure logic of review in the diff (IntelliJ's `GHPRDiffReviewViewModel`, `GHPRDiffViewModel`,
* `GHPRThreadsViewModels`, `GHPRReviewNewCommentEditorViewModel`, `PatchHunkUtil`):
*
* - a file's patch (GitHub's `patch` of the changed file) parsed into hunks, the commentable
* ranges of each side (the hunks' lines), line mapping across a patch, and the "position"
* of a line (what commit comments are addressed by);
* - where a review thread sits in the cumulative diff or in a single commit's diff, and
* whether the "Show Review Threads" option shows it;
* - the new comment's GitHub input (`line`, `side`, `startLine`, `startSide`) and its submit
* actions;
* - Previous / Next Comment: the threads in unified file / line order.
*
* Lines are 0-based unless the name says otherwise (GitHub's are 1-based).
*/
import type { GHDiffSide, GHPullRequestDraftReviewThread, GHPullRequestReviewThread } from "../api/types";
export type Side = "left" | "right";
export const toGitHubSide = (side: Side): GHDiffSide => (side === "left" ? "LEFT" : "RIGHT");
export const fromGitHubSide = (side: GHDiffSide): Side => (side === "LEFT" ? "left" : "right");
// ---------------------------------------------------------------------------
// Patches
export interface PatchHunk {
/** 0-based first line of each side. */
leftStart: number;
leftCount: number;
rightStart: number;
rightCount: number;
/** The rows after the header, with their first character (" ", "+", "-", "\"). */
rows: string[];
}
const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
/** The hunks of a file's patch (GitHub's `patch`: hunks without the file header). */
export function parsePatch(patch: string | null | undefined): PatchHunk[] {
const rows = (patch ?? "").replace(/\r\n?/g, "\n").split("\n");
while (rows.length && rows[rows.length - 1] === "") rows.pop();
const hunks: PatchHunk[] = [];
for (const row of rows) {
const h = HEADER.exec(row);
if (h) {
const leftCount = h[2] === undefined ? 1 : Number(h[2]);
const rightCount = h[4] === undefined ? 1 : Number(h[4]);
// An empty side's start is the line before it (`-0,0`, `+10,0`).
hunks.push({
leftStart: leftCount ? Number(h[1]) - 1 : Number(h[1]),
leftCount,
rightStart: rightCount ? Number(h[3]) - 1 : Number(h[3]),
rightCount,
rows: [],
});
} else hunks[hunks.length - 1]?.rows.push(row || " ");
}
return hunks;
}
/** A half-open line range `[start, end)`. */
export interface Span {
start: number;
end: number;
}
/** The commentable lines of each side (`patch.ranges`): the lines of every hunk, context included. */
export function commentableRanges(hunks: readonly PatchHunk[]): Record<Side, Span[]> {
const out: Record<Side, Span[]> = { left: [], right: [] };
for (const h of hunks) {
if (h.leftCount) out.left.push({ start: h.leftStart, end: h.leftStart + h.leftCount });
if (h.rightCount) out.right.push({ start: h.rightStart, end: h.rightStart + h.rightCount });
}
return out;
}
/**
* The line of the other side matching `line` of `from` (`forcefullyMapLine`): outside the
* hunks lines shift by what the hunks before added or removed; inside, context lines map
* exactly and changed lines to the other side's line at that point.
*/
export function mapAcrossPatch(hunks: readonly PatchHunk[], from: Side, line: number): number {
const mine = from === "left" ? "-" : "+";
const theirs = from === "left" ? "+" : "-";
let shift = 0;
for (const h of hunks) {
const [start, count, otherStart, otherCount] =
from === "left"
? [h.leftStart, h.leftCount, h.rightStart, h.rightCount]
: [h.rightStart, h.rightCount, h.leftStart, h.leftCount];
if (line < start) break;
if (line < start + count) {
let here = start;
let there = otherStart;
for (const row of h.rows) {
const c = row[0];
if (c === "\\") continue;
if (c === theirs) there++;
else {
if (here === line) return there;
here++;
if (c !== mine) there++;
}
}
return there;
}
shift = otherStart + otherCount - (start + count);
}
return Math.max(0, line + shift);
}
/**
* GitHub's diff "position" of `line` of `side` (`PatchHunkUtil.findDiffFileLineIndex`): the
* row's index counted from the first hunk header (the row under it is 1, later headers
* count too); null when the line is not in the patch.
*/
export function diffPosition(patch: string | null | undefined, side: Side, line: number): number | null {
let position = 0;
let started = false;
let left = 0;
let right = 0;
for (const row of (patch ?? "").replace(/\r\n?/g, "\n").split("\n")) {
const h = HEADER.exec(row);
if (h) {
if (started) position++;
started = true;
left = Number(h[1]) - 1;
right = Number(h[3]) - 1;
continue;
}
if (!started) continue;
position++;
const c = row[0] ?? " ";
if (c === "\\") continue;
if (c !== "+") {
if (side === "left" && left === line) return position;
left++;
}
if (c !== "-") {
if (side === "right" && right === line) return position;
right++;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Threads in a diff
/** A comment's place: the last line (where its inlay goes) and the first. */
export interface CommentLocation {
side: Side;
line: number;
startSide: Side;
startLine: number;
}
/** Which diff of the pull request is shown: the cumulative one, or one commit's (`commit`). */
export interface ReviewDiffKind {
commit: string | null;
}
/**
* Where `thread` sits in the diff (`mapThreadToChange`), or null when it can't be placed:
* the cumulative diff places current threads at their `line` / `startLine`; a single commit's
* diff places right-side threads made on that commit at their original lines (and those whose
* current commit it is at their lines). Outdated threads have no place in the cumulative diff.
*/
export function threadLocation(thread: GHPullRequestReviewThread, diff: ReviewDiffKind): CommentLocation | null {
if (thread.subjectType === "FILE") return null;
const root = thread.comments.nodes[0];
const side = fromGitHubSide(thread.diffSide);
const at = (line: number | null, start: number | null): CommentLocation | null =>
line == null
? null
: {
side,
line: line - 1,
startSide: start == null ? side : fromGitHubSide(thread.startDiffSide ?? thread.diffSide),
startLine: (start ?? line) - 1,
};
if (diff.commit === null) return at(thread.line, thread.startLine);
if (side !== "right") return null;
if (root?.originalCommit?.oid === diff.commit) return at(thread.originalLine, thread.originalStartLine);
if (root?.commit?.oid === diff.commit) return at(thread.line, thread.startLine);
return null;
}
/** "Show Review Threads" (`DiscussionsViewOption`). */
export type ThreadsViewOption = "all" | "unresolved" | "none";
export const THREADS_VIEW_OPTIONS: ReadonlyArray<{ value: ThreadsViewOption; label: string }> = [
{ value: "all", label: "All" },
{ value: "unresolved", label: "Unresolved Only" },
{ value: "none", label: "Do Not Show" },
];
export function isThreadVisible(thread: Pick<GHPullRequestReviewThread, "isResolved">, option: ThreadsViewOption) {
return option === "all" || (option === "unresolved" && !thread.isResolved);
}
// ---------------------------------------------------------------------------
// New comments
/** `createThreadDTO`: the GitHub input of a new comment on `path` at `loc`. */
export function draftThread(path: string, loc: CommentLocation, body: string): GHPullRequestDraftReviewThread {
const single = loc.startSide === loc.side && loc.startLine === loc.line;
return {
body,
path,
line: loc.line + 1,
side: toGitHubSide(loc.side),
startLine: single ? null : loc.startLine + 1,
startSide: single ? null : toGitHubSide(loc.startSide),
};
}
/** The location of a new comment on the 0-based lines `start`..`end` of one side. */
export function newCommentLocation(side: Side, start: number, end: number): CommentLocation {
return { side, line: Math.max(start, end), startSide: side, startLine: Math.min(start, end) };
}
export type SubmitAction = "startReview" | "singleComment" | "reviewComment";
/**
* The new comment's buttons, the primary one first: with a pending review "Add Review Comment";
* else "Start Review" and "Add Single Comment", the one used last first (`reviewCommentsPreferred`).
*/
export function submitActions(hasPendingReview: boolean, reviewPreferred: boolean): SubmitAction[] {
if (hasPendingReview) return ["reviewComment"];
return reviewPreferred ? ["startReview", "singleComment"] : ["singleComment", "startReview"];
}
export const SUBMIT_ACTION_TEXT: Record<SubmitAction, string> = {
startReview: "Start Review",
singleComment: "Add Single Comment",
reviewComment: "Add Review Comment",
};
// ---------------------------------------------------------------------------
// Previous / Next Comment
/** A thread's place in the unified order (`UnifiedCodeReviewItemPosition`): file, then right, then left line. */
export interface ThreadPosition {
id: string;
/** Index of the file in the pull request's changes. */
file: number;
path: string;
left: number;
right: number;
createdAt: string;
/** Where navigation reveals it. */
side: Side;
line: number;
}
export function comparePositions(
a: Pick<ThreadPosition, "file" | "left" | "right">,
b: Pick<ThreadPosition, "file" | "left" | "right">,
) {
return a.file - b.file || a.right - b.right || a.left - b.left;
}
/**
* The threads of the cumulative diff in navigation order (`createSortedThreadPositionsAndIds`):
* each thread's line is mapped to both sides with its file's patch.
*/
export function threadOrder(
files: ReadonlyArray<{ path: string; hunks: readonly PatchHunk[] }>,
threads: readonly GHPullRequestReviewThread[],
): ThreadPosition[] {
const index = new Map(files.map((f, i) => [f.path, i]));
const out: ThreadPosition[] = [];
for (const t of threads) {
const file = index.get(t.path);
const loc = threadLocation(t, { commit: null });
if (file === undefined || !loc) continue;
const other = mapAcrossPatch(files[file]!.hunks, loc.side, loc.line);
out.push({
id: t.id,
file,
path: t.path,
left: loc.side === "left" ? loc.line : other,
right: loc.side === "right" ? loc.line : other,
createdAt: t.comments.nodes[0]?.createdAt ?? "",
side: loc.side,
line: loc.line,
});
}
return out.sort((a, b) => comparePositions(a, b) || a.createdAt.localeCompare(b.createdAt));
}
/**
* The visible thread after (`dir` 1) or before (-1) the current one (`from.id`) or the caret
* (`from` a position: the thread on the caret's line counts as next / previous).
*/
export function adjacentThread(
order: readonly ThreadPosition[],
from: { id: string } | Pick<ThreadPosition, "file" | "left" | "right">,
dir: 1 | -1,
visible: (id: string) => boolean = () => true,
): ThreadPosition | null {
let i: number;
if ("id" in from) {
const at = order.findIndex((p) => p.id === from.id);
if (at < 0) return null;
i = at + dir;
} else if (dir === 1) {
i = order.findIndex((p) => comparePositions(p, from) >= 0);
if (i < 0) return null;
} else {
i = order.length - 1;
while (i >= 0 && comparePositions(order[i]!, from) > 0) i--;
}
for (; i >= 0 && i < order.length; i += dir) if (visible(order[i]!.id)) return order[i]!;
return null;
}
+133
View File
@@ -0,0 +1,133 @@
/**
* New review comments being written in diffs (IntelliJ's `GHPRThreadsViewModels.newComments`
* with `GHPRReviewNewCommentEditorViewModel`): kept per pull request, file and diff (the
* cumulative one or a commit's) while the diff tabs come and go, and submitted with
*
* - "Start Review" / "Add Single Comment" without a pending review: `createReview` with the
* comment as its thread (pending, or posted at once with `event: COMMENT`); the choice
* becomes the preferred button;
* - "Add Review Comment" with one: `addPullRequestReviewThread` on the cumulative diff, a
* commit comment at the line's diff position (merge base ↔ commit) on a commit's diff.
*/
import { create } from "zustand";
import { compareChangedFiles } from "../api/requests";
import { addCommitComment, createReview, createThread } from "../data/operations";
import { loadPullRequestData } from "../data/pullRequest";
import { diffPosition, draftThread, type CommentLocation, type SubmitAction } from "./model";
import { setReviewDiffSettings } from "./settings";
export interface NewComment {
id: string;
number: number;
/** Repository-relative path. */
path: string;
/** The commit whose diff it is on; null for the pull request's changes. */
commit: string | null;
location: CommentLocation;
text: string;
createdAt: string;
/** Bumped to move the focus into its editor. */
focus: number;
}
export const useNewComments = create<{ comments: NewComment[] }>(() => ({ comments: [] }));
let seq = 0;
const sameDiff = (c: NewComment, number: number, path: string, commit: string | null) =>
c.number === number && c.path === path && c.commit === commit;
/** The new comments of a diff. */
export function newCommentsOf(number: number, path: string, commit: string | null): NewComment[] {
return useNewComments.getState().comments.filter((c) => sameDiff(c, number, path, commit));
}
/** `requestNewComment`: opens a new comment at `location`, or focuses the one already there. */
export function requestNewComment(
number: number,
path: string,
commit: string | null,
location: CommentLocation,
): NewComment {
const existing = newCommentsOf(number, path, commit).find(
(c) =>
c.location.side === location.side &&
c.location.line === location.line &&
c.location.startLine === location.startLine,
);
if (existing) {
updateNewComment(existing.id, { focus: existing.focus + 1 });
return existing;
}
const comment: NewComment = {
id: `new-${++seq}`,
number,
path,
commit,
location,
text: "",
createdAt: new Date().toISOString(),
focus: 1,
};
useNewComments.setState((s) => ({ comments: [...s.comments, comment] }));
return comment;
}
export function updateNewComment(id: string, patch: Partial<Omit<NewComment, "id">>) {
useNewComments.setState((s) => ({ comments: s.comments.map((c) => (c.id === id ? { ...c, ...patch } : c)) }));
}
export function removeNewComment(id: string) {
useNewComments.setState((s) => ({ comments: s.comments.filter((c) => c.id !== id) }));
}
/** Forgets every new comment (another repository). */
export function clearNewComments() {
useNewComments.setState({ comments: [] });
}
/** The revision the comment's diff shows on the right: the commit, else the pull request's head. */
async function afterRevision(c: NewComment): Promise<string> {
return c.commit ?? (await loadPullRequestData(c.number, "details")).headRefOid;
}
/** Sends `body` with `action`; the comment is closed once it is created. */
export async function submitNewComment(c: NewComment, action: SubmitAction, body: string): Promise<void> {
if (action === "reviewComment") {
const pending = await loadPullRequestData(c.number, "pendingReview");
if (!pending) throw new Error("No pending review");
if (c.commit === null) {
const t = draftThread(c.path, c.location, body);
await createThread(c.number, {
reviewId: pending.id,
body,
path: c.path,
line: t.line,
side: t.side,
startLine: t.startLine ?? undefined,
startSide: t.startSide ?? undefined,
});
} else {
const pr = await loadPullRequestData(c.number, "details");
const files = await compareChangedFiles(pr.baseRefOid, c.commit);
const patch = files.find((f) => f.filename === c.path)?.patch;
const position = diffPosition(patch, c.location.side, c.location.line);
if (position === null) throw new Error("Can't map file line to diff");
await addCommitComment(c.number, {
reviewId: pending.id,
body,
commitSha: c.commit,
path: c.path,
position,
});
}
} else {
await createReview(c.number, {
event: action === "singleComment" ? "COMMENT" : undefined,
commitOid: await afterRevision(c),
threads: [draftThread(c.path, c.location, body)],
});
setReviewDiffSettings({ reviewCommentsPreferred: action === "startReview" });
}
removeNewComment(c.id);
}
@@ -0,0 +1,380 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ContextMenuHost } from "@/ui";
import type { EditorInput } from "@/workbench/contributions";
const texts: Record<string, string> = {};
const ghApi = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
fsProbe: () => Promise.resolve({ status: "error", error: "none" }),
vcsFileAt: (_path: string, rev: string) => Promise.resolve({ status: "ok", data: texts[rev] ?? "" }),
ghApi: (req: unknown) => ghApi(req),
},
};
});
import { DiffEditor } from "@/features/vcs/DiffEditor";
import { setCurrentRepository } from "../api/client";
import type {
GHCommitFile,
GHPullRequest,
GHPullRequestReviewComment,
GHPullRequestReviewThread,
GHRepository,
GHUser,
} from "../api/types";
import { PULL_REQUEST_DIFF } from "../data/local";
import { clearPullRequests, setPullRequestData } from "../data/pullRequest";
import { useGhRepository } from "../data/repository";
import { activateReviewDiff } from ".";
import { clearNewComments, requestNewComment, useNewComments } from "./newComments";
import { newCommentLocation } from "./model";
import { setReviewDiffSettings, useReviewDiffSettings } from "./settings";
const N = 7;
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 };
let commentSeq = 0;
const comment = (body: string, over: Partial<GHPullRequestReviewComment> = {}): GHPullRequestReviewComment =>
({
id: `C${++commentSeq}`,
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: [] },
...over,
}) as GHPullRequestReviewComment;
const thread = (id: string, line: number, bodies: string[], 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: bodies.map((b) => comment(b)) },
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,
};
const input = (commit: string | null = null) =>
({
kind: "diff",
key: `diff:${commit}`,
path: `/p/${PATH}`,
data: {
path: `/p/${PATH}`,
left: { type: "rev", rev: commit ? `${commit}^` : "base" },
right: { type: "rev", rev: commit ?? "head" },
context: { kind: PULL_REQUEST_DIFF, number: N, path: PATH, commit },
},
}) as unknown as EditorInput;
/** The GraphQL / REST calls made, by operation. */
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,
);
}
function respond(req: { endpoint: string; body?: string }): string {
if (req.endpoint.includes("/compare/")) return JSON.stringify({ files: [{ ...file, patch: PATCH }] });
const q = req.endpoint === "graphql" ? (JSON.parse(req.body!) as { query: string }).query : "";
const t = thread("T-new", 4, ["new"]);
if (/addPullRequestReview\s*\(/.test(q))
return JSON.stringify({
data: {
addPullRequestReview: { pullRequestReview: { id: "PRR1", state: "PENDING", comments: { totalCount: 1 } } },
},
});
if (/addPullRequestReviewThread\s*\(/.test(q))
return JSON.stringify({ data: { addPullRequestReviewThread: { thread: t } } });
if (/addPullRequestReviewComment\s*\(/.test(q))
return JSON.stringify({ data: { addPullRequestReviewComment: { comment: comment("c") } } });
if (/resolveReviewThread\s*\(/.test(q))
return JSON.stringify({ data: { resolveReviewThread: { thread: { ...t, isResolved: true } } } });
// Reloads fail: the cached data stays.
return JSON.stringify({ errors: [{ message: "not stubbed" }] });
}
let deactivate: () => void = () => {};
beforeEach(() => {
localStorage.clear();
useReviewDiffSettings.setState({ threadsView: "all", reviewCommentsPreferred: true, autoMarkViewed: false });
texts.base = BASE;
texts.head = HEAD;
texts["c1^"] = BASE;
texts.c1 = HEAD;
ghApi.mockReset();
ghApi.mockImplementation((req: { endpoint: string; body?: string }) =>
Promise.resolve({ status: "ok", data: respond(req) }),
);
setCurrentRepository({ owner: "o", name: "r", id: "R1" });
useGhRepository.setState({
connection: {
coordinates: { owner: "o", name: "r" },
repository: { id: "R1", viewerPermission: "READ" } as GHRepository,
currentUser: me,
},
});
setPullRequestData(
N,
"details",
() => ({ id: "PR1", number: N, headRefOid: "head", baseRefOid: "base" }) as GHPullRequest,
);
setPullRequestData(N, "changes", () => [file]);
setPullRequestData(N, "viewedState", () => ({ [PATH]: "UNVIEWED" }));
setPullRequestData(N, "pendingReview", () => null);
setPullRequestData(N, "threads", () => [
thread("T1", 3, ["first", "second", "third", "fourth"]),
thread("T2", 5, ["done"], { isResolved: true }),
]);
deactivate = activateReviewDiff();
});
afterEach(() => {
cleanup();
deactivate();
clearNewComments();
clearPullRequests();
useGhRepository.setState({ connection: null });
});
const view = (commit: string | null = null) =>
render(
<>
<DiffEditor input={input(commit)} />
<ContextMenuHost />
</>,
);
/** The new comment's inlay, once shown. */
const newCommentBox = () =>
waitFor(() => {
const el = document.querySelector<HTMLElement>("[data-new-comment]");
if (!el) throw new Error("no new comment inlay");
return el;
});
const inlay = (id: string) => document.querySelector<HTMLElement>(`[data-thread-inlay="${id}"]`);
describe("review threads in a pull request diff", () => {
it("shows threads as compact inlays under their lines, filtered by Show Review Threads", async () => {
view();
await waitFor(() => expect(inlay("T1")).not.toBeNull());
const t1 = inlay("T1")!;
// More than 3 comments: the first, "… 2 more replies", the last.
expect(t1.textContent).toContain("first");
expect(t1.textContent).toContain("2 more replies");
expect(t1.textContent).not.toContain("second");
expect(t1.textContent).toContain("fourth");
// Under line 3 of the right side.
const right = document.querySelector(".cm-merge-b")!;
expect(right.contains(t1)).toBe(true);
expect(t1.closest(".cm-lineInlay")!.previousElementSibling?.textContent).toBe("line three");
expect(right.querySelectorAll(".cm-reviewControl-toggle")).toHaveLength(2);
expect(inlay("T2")).not.toBeNull();
act(() => setReviewDiffSettings({ threadsView: "unresolved" }));
await waitFor(() => expect(inlay("T2")).toBeNull());
expect(inlay("T1")).not.toBeNull();
act(() => setReviewDiffSettings({ threadsView: "none" }));
await waitFor(() => expect(inlay("T1")).toBeNull());
});
it("follows the threads when the data reloads", async () => {
view();
await waitFor(() => expect(inlay("T1")).not.toBeNull());
act(() => setPullRequestData(N, "threads", () => [thread("T9", 1, ["fresh"])]));
await waitFor(() => expect(inlay("T9")).not.toBeNull());
expect(inlay("T1")).toBeNull();
});
it("replies to and resolves a thread", async () => {
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 0 }));
view();
await waitFor(() => expect(inlay("T1")).not.toBeNull());
const t1 = within(inlay("T1")!);
fireEvent.click(t1.getByRole("button", { name: "Reply" }));
fireEvent.change(t1.getByRole("textbox"), { target: { value: "thanks" } });
const replies = t1.getAllByRole("button", { name: "Reply" });
fireEvent.click(replies[replies.length - 1]!);
await waitFor(() =>
expect(calls(/addPullRequestReviewComment/)).toEqual([
expect.objectContaining({ reviewId: "PRR0", body: "thanks", inReplyTo: expect.stringMatching(/^C/) }),
]),
);
fireEvent.click(within(inlay("T1")!).getAllByRole("button", { name: "Resolve" })[0]!);
await waitFor(() => expect(calls(/resolveReviewThread/)).toEqual([{ threadId: "T1" }]));
});
});
describe("new comments", () => {
it("Start Review creates a pending review with the comment's thread and becomes the preferred action", async () => {
view();
await waitFor(() => expect(inlay("T1")).not.toBeNull());
act(() => void requestNewComment(N, PATH, null, newCommentLocation("right", 1, 3)));
const box = await newCommentBox();
// Without a pending review: Add Single Comment, then the preferred Start Review (primary).
const buttons = within(box).getAllByRole("button");
expect(buttons.map((b) => b.textContent)).toEqual(["Add Single Comment", "Start Review"]);
// The range is outlined; on the changes it can be adjusted.
expect(document.querySelectorAll(".cm-merge-b .cm-reviewRange")).toHaveLength(3);
expect(document.querySelector<HTMLElement>(".cm-reviewHandle")?.title).toBe(
"Drag to select several lines for commenting",
);
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "Looks odd" } });
fireEvent.click(within(box).getByRole("button", { name: "Start Review" }));
await waitFor(() => expect(useNewComments.getState().comments).toHaveLength(0));
expect(calls(/addPullRequestReview\s*\(/)).toEqual([
{
pullRequestId: "PR1",
commitOid: "head",
threads: [{ body: "Looks odd", path: PATH, line: 4, side: "RIGHT", startLine: 2, startSide: "RIGHT" }],
},
]);
expect(useReviewDiffSettings.getState().reviewCommentsPreferred).toBe(true);
});
it("Add Single Comment posts the comment at once and becomes the primary button", async () => {
view();
await waitFor(() => expect(inlay("T1")).not.toBeNull());
act(() => void requestNewComment(N, PATH, null, newCommentLocation("left", 4, 4)));
const box = await newCommentBox();
// A left-side comment goes into the left pane.
expect(document.querySelector(".cm-merge-a")!.contains(box)).toBe(true);
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "hm" } });
fireEvent.click(within(box).getByRole("button", { name: "Add Single Comment" }));
await waitFor(() => expect(calls(/addPullRequestReview\s*\(/)).toHaveLength(1));
expect(calls(/addPullRequestReview\s*\(/)[0]).toMatchObject({
event: "COMMENT",
threads: [{ body: "hm", path: PATH, line: 5, side: "LEFT", startLine: null, startSide: null }],
});
await waitFor(() => expect(useReviewDiffSettings.getState().reviewCommentsPreferred).toBe(false));
});
it("Add Review Comment adds a thread to the pending review", async () => {
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 2 }));
view();
await screen.findByRole("button", { name: "Submit Review (2)…" });
act(() => void requestNewComment(N, PATH, null, newCommentLocation("right", 0, 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("comments on a single commit's diff at the line's diff position, with a fixed range", async () => {
setPullRequestData(N, "pendingReview", () => ({ id: "PRR0", state: "PENDING", commentsCount: 0 }));
view("c1");
await waitFor(() => expect(inlay("T1")).toBeNull());
act(() => void requestNewComment(N, PATH, "c1", newCommentLocation("right", 2, 2)));
const box = await newCommentBox();
expect(document.querySelector<HTMLElement>(".cm-reviewHandle")?.title).toBe(
"Comment range cannot be adjusted while reviewing a single commit",
);
fireEvent.change(within(box).getByRole("textbox"), { target: { value: "here" } });
fireEvent.click(within(box).getByRole("button", { name: "Add Review Comment" }));
await waitFor(() =>
expect(calls(/addPullRequestReviewComment/)).toEqual([
{ reviewId: "PRR0", body: "here", commit: "c1", file: PATH, position: 4 },
]),
);
expect(calls(/compare\/base\.\.\.c1/)).toHaveLength(1);
});
it("discards an empty new comment on Escape", async () => {
view();
act(() => void requestNewComment(N, PATH, null, newCommentLocation("right", 1, 1)));
const box = await newCommentBox();
fireEvent.keyDown(within(box).getByRole("textbox"), { key: "Escape" });
await waitFor(() => expect(document.querySelector("[data-new-comment]")).toBeNull());
});
});
describe("viewed state", () => {
it("marks the file viewed from the editor menu", async () => {
view();
await waitFor(() => expect(inlay("T1")).not.toBeNull());
const line = [...document.querySelectorAll(".cm-merge-b .cm-line")].find((l) => l.textContent === "line 2")!;
fireEvent.contextMenu(line, { clientX: 5, clientY: 5 });
const item = await screen.findByRole("menuitem", { name: /Mark File as Viewed/ });
expect(screen.getByRole("menuitem", { name: "Add Review Comment" })).toBeTruthy();
fireEvent.click(item);
await waitFor(() => expect(calls(/markFileAsViewed/)).toEqual([{ pullRequestId: "PR1", path: PATH }]));
});
it("marks opened files viewed when Automatically mark opened files as viewed is on", async () => {
useReviewDiffSettings.setState({ autoMarkViewed: true });
view();
await waitFor(() => expect(calls(/markFileAsViewed/)).toEqual([{ pullRequestId: "PR1", path: PATH }]));
});
it("does not mark a single commit's file viewed", async () => {
useReviewDiffSettings.setState({ autoMarkViewed: true });
view("c1");
await screen.findByRole("button", { name: "Submit Review…" });
await new Promise((r) => setTimeout(r, 20));
expect(calls(/markFileAsViewed/)).toEqual([]);
});
});
+486
View File
@@ -0,0 +1,486 @@
/**
* Review in a pull request's diff (IntelliJ's `GHPRReviewDiffExtension` with its
* `DiffEditorModel`): a diff viewer contribution for diffs whose context is a pull request's
* file (`PULL_REQUEST_DIFF`). For each pane it keeps
*
* - the inlays: the visible review threads under their lines (on their side; the unified
* viewer maps left lines to the right side) and the new comments being written;
* - the gutter controls: comment icons, the "+" on commentable lines (the patch's hunks of
* that side, `canComment` = read access), new comments' range outlines (adjustable on the
* pull request's changes unless the text has a suggestion).
*
* It follows the pull request's data, the new comments and the settings, and adds the
* toolbar (Refresh Review Data, Submit Review…, Previous / Next Comment), "Show Review
* Threads" to the viewer settings and the editor menu items (Add Review Comment, Add
* Multiline Review Comment, Mark File as Viewed). Opening it marks the file viewed when
* "Automatically mark opened files as viewed" is on.
*/
import type { Extension } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import type { DiffData } from "@/features/vcs/diffInput";
import type {
DiffPaneInfo,
DiffPaneSide,
DiffViewerContribution,
DiffViewerHost,
DiffViewerPane,
} from "@/features/vcs/diffViewerProviders";
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 { setContext } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
import type { MenuItem } from "@/ui";
import { commitChangedFiles } from "../api/requests";
import type { GHCommitFile, GHPullRequestReviewThread } from "../api/types";
import { openPullRequestFileDiff, PULL_REQUEST_DIFF } from "../data/local";
import { setViewed } from "../data/operations";
import {
loadPullRequestData,
pullRequestData,
reloadPullRequestData,
subscribePullRequestData,
} from "../data/pullRequest";
import { currentUserHasPermission, useGhRepository } from "../data/repository";
import { containsSuggestedChange } from "../timeline/model";
import { discardNewComment, NewCommentInlay, ThreadInlay } from "./Inlays";
import {
adjacentThread,
commentableRanges,
isThreadVisible,
mapAcrossPatch,
newCommentLocation,
parsePatch,
threadLocation,
threadOrder,
THREADS_VIEW_OPTIONS,
type CommentLocation,
type PatchHunk,
type Side,
type Span,
} from "./model";
import { newCommentsOf, requestNewComment, updateNewComment, useNewComments } from "./newComments";
import { setReviewDiffSettings, useReviewDiffSettings } from "./settings";
import { ReviewToolbar } from "./ReviewToolbar";
export interface ReviewDiffContext {
number: number;
/** Repository-relative path. */
path: string;
/** The commit whose diff this is; null for the pull request's changes. */
commit: string | null;
}
/** The pull request file a diff shows, from its context; null for other diffs. */
export function reviewDiffContext(data: DiffData): ReviewDiffContext | null {
const c = data.context;
if (c?.kind !== PULL_REQUEST_DIFF || typeof c.number !== "number" || typeof c.path !== "string") return null;
return { number: c.number, path: c.path, commit: typeof c.commit === "string" ? c.commit : null };
}
/** Tooltips of a new comment's range outline (`AdjustmentDisabledReason`). */
const outlineTooltip = (reason: "suggestion" | "commit" | null) =>
reason === "commit"
? tx("Comment range cannot be adjusted while reviewing a single commit")
: reason === "suggestion"
? tx("The range of this comment is not adjustable as it contains suggestion")
: tx("Drag to select several lines for commenting");
/** The files of single commits (their patches give the commentable lines), per commit. */
const commitFiles = new Map<string, Promise<GHCommitFile[]>>();
function filesOfCommit(sha: string): Promise<GHCommitFile[]> {
let p = commitFiles.get(sha);
if (!p) {
p = commitChangedFiles(sha);
p.catch(() => commitFiles.delete(sha));
commitFiles.set(sha, p);
}
return p;
}
/** 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 > 500) hunkCache.clear();
hunkCache.set(patch, h);
}
return h;
}
/** 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; side: Side; line: number } | null = null;
/** The review diff focused last (the editor actions apply to it). */
let active: ReviewDiff | null = null;
export const activeReviewDiff = () => active;
function setActive(diff: ReviewDiff | null) {
active = diff;
setContext("githubReviewDiff", !!diff);
}
export class ReviewDiff implements DiffViewerContribution {
private host: DiffViewerHost | null = null;
/** Threads hidden with the gutter's comment icon (`Hideable`). */
private hidden = new Set<string>();
private commitPatch: string | null | undefined = undefined;
private gutters = new Map<EditorView, ReviewGutterState>();
private disposers: Array<() => void> = [];
readonly Toolbar = () => <ReviewToolbar diff={this} />;
constructor(readonly ctx: ReviewDiffContext) {
const n = ctx.number;
for (const key of ["details", "threads", "pendingReview", "changes", "viewedState"] as const)
void loadPullRequestData(n, key).catch(() => undefined);
if (ctx.commit)
void filesOfCommit(ctx.commit).then(
(files) => {
this.commitPatch = files.find((f) => f.filename === ctx.path)?.patch ?? null;
this.push();
},
() => undefined,
);
const push = () => this.push();
this.disposers.push(
subscribePullRequestData(n, push),
useNewComments.subscribe(push),
useReviewDiffSettings.subscribe(push),
useGhRepository.subscribe(push),
);
void this.autoMarkViewed();
}
get number() {
return this.ctx.number;
}
/** Previous / Next Comment work on the pull request's changes only (`canNavigate`). */
get canNavigate() {
return this.ctx.commit === null;
}
dispose() {
this.disposers.forEach((d) => d());
this.disposers = [];
if (active === this) setActive(null);
}
extensions(pane: DiffPaneInfo): Extension {
const side: Side = pane.unified ? "right" : pane.side;
const focus = ViewPlugin.define((view) => {
const onFocus = () => setActive(this);
view.dom.addEventListener("focusin", onFocus);
return { destroy: () => view.dom.removeEventListener("focusin", onFocus) };
});
return [
lineInlays(),
reviewGutter({
requestNewComment: (range) => this.request(side, range),
cancelNewComment: (line) => this.cancelAt(side, line),
toggleComments: (line) => this.toggleAt(side, line),
adjustRange: (key, range) =>
updateNewComment(key, { location: newCommentLocation(side, range.start, range.end) }),
}),
focus,
];
}
attach(host: DiffViewerHost) {
this.host = host;
this.push();
return () => {
if (this.host === host) this.host = null;
for (const p of host.panes()) this.gutters.delete(p.view);
};
}
// ---------------------------------------------------------------------------
// State
private threads(): GHPullRequestReviewThread[] {
return (pullRequestData(this.number, "threads").value ?? []).filter((t) => t.path === this.ctx.path);
}
/** The commentable lines of each side, once the patch is known. */
private commentable(): Record<Side, Span[]> | null {
if (!currentUserHasPermission("READ")) return null;
if (this.ctx.commit) return this.commitPatch === undefined ? null : commentableRanges(hunksOf(this.commitPatch));
const files = pullRequestData(this.number, "changes").value;
if (!files) return null;
return commentableRanges(hunksOf(files.find((f) => f.filename === this.ctx.path)?.patch));
}
/** `line` of `side` in `pane`, or null when it shows elsewhere. */
private lineIn(pane: DiffViewerPane, side: Side, line: number): number | null {
const at = this.host?.locate(side, line);
return at && at.pane.view === pane.view ? at.line : null;
}
private outlineOf(pane: DiffViewerPane, key: string, loc: CommentLocation, text: string): ReviewOutline | null {
const end = this.lineIn(pane, loc.side, loc.line);
const start = this.lineIn(pane, loc.startSide, loc.startLine);
if (end === null || start === null) return null;
const reason = this.ctx.commit ? "commit" : containsSuggestedChange(text) ? "suggestion" : null;
return {
key,
range: { start: Math.min(start, end), end: Math.max(start, end) },
adjustable: reason === null,
tooltip: outlineTooltip(reason),
};
}
/** Recomputes and pushes every pane's inlays and gutter state. */
push() {
const host = this.host;
if (!host) return;
const n = this.number;
const option = useReviewDiffSettings.getState().threadsView;
const threads = this.threads();
const drafts = newCommentsOf(n, this.ctx.path, this.ctx.commit);
const ranges = this.commentable();
for (const pane of host.panes()) {
const inlays: LineInlay[] = [];
const withComments = new Set<number>();
const withInlays = new Set<number>();
const withNew = new Set<number>();
const outlines: ReviewOutline[] = [];
for (const t of threads) {
const loc = isThreadVisible(t, option) ? threadLocation(t, this.ctx) : null;
const line = loc && this.lineIn(pane, loc.side, loc.line);
if (line == null) continue;
withComments.add(line);
if (this.hidden.has(t.id)) continue;
withInlays.add(line);
inlays.push({ key: t.id, line, content: <ThreadInlay number={n} threadId={t.id} /> });
}
for (const c of drafts) {
const line = this.lineIn(pane, c.location.side, c.location.line);
if (line === null) continue;
withNew.add(line);
withInlays.add(line);
inlays.push({ key: c.id, line, content: <NewCommentInlay id={c.id} /> });
const outline = this.outlineOf(pane, c.id, c.location, c.text);
if (outline) outlines.push(outline);
}
const gutter: ReviewGutterState = {
linesWithComments: withComments,
linesWithNewComments: withNew,
linesWithInlays: withInlays,
// The unified viewer's lines are the right side's.
commentable: ranges ? ranges[host.mode === "unified" ? "right" : pane.side] : [],
outlines,
};
this.gutters.set(pane.view, gutter);
pane.view.dispatch({ effects: [setLineInlays.of(inlays), setReviewGutterState.of(gutter)] });
}
}
private gutterOf(view: EditorView): ReviewGutterState {
return this.gutters.get(view) ?? EMPTY_REVIEW_GUTTER;
}
private paneOf(side: Side): DiffViewerPane | null {
const panes = this.host?.panes() ?? [];
return panes.find((p) => p.side === side) ?? (this.host?.mode === "unified" ? (panes[0] ?? null) : null);
}
// ---------------------------------------------------------------------------
// Gutter requests (lines of the pane showing `side`)
private request(side: Side, range: LineRange) {
requestNewComment(this.number, this.ctx.path, this.ctx.commit, newCommentLocation(side, range.start, range.end));
}
private cancelAt(side: Side, line: number) {
const c = newCommentsOf(this.number, this.ctx.path, this.ctx.commit).find(
(x) => x.location.side === side && x.location.line === line,
);
if (c) void discardNewComment(c);
}
/** Shows the threads of the line, or hides them when any is shown (`syncOrToggleAll`). */
private toggleAt(side: Side, line: number) {
const pane = this.paneOf(side);
if (!pane) return;
const ids = this.threads()
.filter((t) => {
const loc = threadLocation(t, this.ctx);
return !!loc && this.lineIn(pane, loc.side, loc.line) === line;
})
.map((t) => t.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.push();
}
// ---------------------------------------------------------------------------
// Actions
/** "Add Review Comment" on the caret's line, "Add Multiline Review Comment" on the selected lines. */
addComment(multiline: boolean, at?: { pane: DiffViewerPane; line: number }) {
const pane = at?.pane ?? this.host?.focusedPane();
if (!pane) return;
const range = this.commentRange(pane, multiline, at?.line);
if (range) this.request(this.host?.mode === "unified" ? "right" : pane.side, range);
}
/** The range the actions would comment on, when they can. */
commentRange(pane: DiffViewerPane, multiline: boolean, line?: number): LineRange | null {
const state = pane.view.state;
const sel = state.selection.main;
const gutter = this.gutterOf(pane.view);
if (multiline) {
const range = { start: state.doc.lineAt(sel.from).number - 1, end: state.doc.lineAt(sel.to).number - 1 };
return range.start < range.end && canCreateComment(gutter, range) ? range : null;
}
const l = line ?? state.doc.lineAt(sel.head).number - 1;
return isLineCommentable(gutter, l) ? { start: l, end: l } : null;
}
/** "Mark File as Viewed" / "Mark File as Not Viewed": the current state, null without one (a commit's diff). */
viewed(): boolean | null {
if (this.ctx.commit) return null;
const state = pullRequestData(this.number, "viewedState").value;
return state ? state[this.ctx.path] === "VIEWED" : null;
}
toggleViewed() {
const viewed = this.viewed();
if (viewed !== null) void setViewed(this.number, [this.ctx.path], !viewed).catch(() => undefined);
}
private async autoMarkViewed() {
if (this.ctx.commit || !useReviewDiffSettings.getState().autoMarkViewed) return;
try {
const state = await loadPullRequestData(this.number, "viewedState");
if (state[this.ctx.path] !== "VIEWED") await setViewed(this.number, [this.ctx.path], true);
} catch {
/* IntelliJ ignores it too */
}
}
/** "Refresh Review Data": the pending review and the threads. */
refresh() {
void reloadPullRequestData(this.number, "pendingReview").catch(() => undefined);
void reloadPullRequestData(this.number, "threads").catch(() => undefined);
}
/** 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 host = this.host;
if (!host || !this.canNavigate) return false;
const n = this.number;
const [files, threads] = await Promise.all([loadPullRequestData(n, "changes"), loadPullRequestData(n, "threads")]);
const option = useReviewDiffSettings.getState().threadsView;
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 && isThreadVisible(t, option);
};
const pane = host.focusedPane();
const side: Side = host.mode === "unified" ? "right" : pane.side;
const line = pane.view.state.doc.lineAt(pane.view.state.selection.main.head).number - 1;
const onLast =
navigated?.number === n && navigated.path === this.ctx.path && navigated.side === side && navigated.line === line;
const file = files.findIndex((f) => f.filename === this.ctx.path);
const other = host.mapLine(side, line);
const from = onLast
? { id: navigated!.id }
: { file, left: side === "left" ? line : other, right: side === "right" ? line : other };
const target = adjacentThread(order, from, dir, visible);
if (!target) return false;
// Where the caret lands: the unified viewer shows left lines at their right-side line.
const mapped = host.mode === "unified" && target.side === "left";
const caretAt = (rightLine: number) => (mapped ? { side: "right" as const, line: rightLine } : target);
if (target.path === this.ctx.path) {
const at = caretAt(host.mapLine("left", target.line));
navigated = { number: n, id: target.id, path: target.path, side: at.side, line: at.line };
this.hidden.delete(target.id);
this.push();
host.reveal(target.side, target.line);
} else {
const hunks = hunksOf(files.find((f) => f.filename === target.path)?.patch);
const at = caretAt(mapAcrossPatch(hunks, "left", target.line));
navigated = { number: n, id: target.id, path: target.path, side: at.side, line: at.line };
await openPullRequestFileDiff(n, target.path, { line: target.line, side: target.side });
}
return true;
}
// ---------------------------------------------------------------------------
// Viewer settings and menus
settingsItems(): MenuItem[] {
const current = useReviewDiffSettings.getState().threadsView;
return [
{
id: "github.reviewThreads",
label: "Show Review Threads",
icon: "eye",
submenu: THREADS_VIEW_OPTIONS.map((o) => ({
id: o.value,
label: o.label,
checked: current === o.value,
run: () => setReviewDiffSettings({ threadsView: o.value }),
})),
},
];
}
contextMenuItems(at: { side: DiffPaneSide; line: number }): MenuItem[] {
const pane = this.host?.panes().find((p) => p.side === at.side);
if (!pane) return [];
const viewed = this.viewed();
return [
{
id: "github.addReviewComment",
label: "Add Review Comment",
disabled: !this.commentRange(pane, false, at.line),
run: () => this.addComment(false, { pane, line: at.line }),
},
{
id: "github.addMultilineReviewComment",
label: "Add Multiline Review Comment",
disabled: !this.commentRange(pane, true),
run: () => this.addComment(true, { pane, line: at.line }),
},
...(viewed === null
? []
: [
{
id: "github.toggleViewed",
label: viewed ? "Mark File as Not Viewed" : "Mark File as Viewed",
shortcut: shortcutLabel("github.pullRequest.toggleViewed"),
run: () => this.toggleViewed(),
},
]),
];
}
}
export function createReviewDiff(data: DiffData): ReviewDiff | null {
const ctx = reviewDiffContext(data);
return ctx ? new ReviewDiff(ctx) : null;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Review-in-diff settings, persisted in local storage: "Show Review Threads"
* (`GithubPullRequestsProjectUISettings.editorReviewViewOption`), the new comment's preferred
* button (`reviewCommentsPreferred`) and "Automatically mark opened files as viewed"
* (`GithubSettings.isAutomaticallyMarkAsViewed`, on Settings › Version Control › GitHub).
*/
import { create } from "zustand";
import { THREADS_VIEW_OPTIONS, type ThreadsViewOption } from "./model";
export interface ReviewDiffSettings {
threadsView: ThreadsViewOption;
/** "Start Review" is the primary button of a new comment (else "Add Single Comment"). */
reviewCommentsPreferred: boolean;
autoMarkViewed: boolean;
}
const KEY = "openidea.github.reviewDiff.v1";
/** IntelliJ's defaults. */
const DEFAULTS: ReviewDiffSettings = {
threadsView: "unresolved",
reviewCommentsPreferred: true,
autoMarkViewed: false,
};
export function parseReviewDiffSettings(raw: unknown): ReviewDiffSettings {
const o = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const bool = (v: unknown, d: boolean) => (typeof v === "boolean" ? v : d);
return {
threadsView: THREADS_VIEW_OPTIONS.some((x) => x.value === o.threadsView)
? (o.threadsView as ThreadsViewOption)
: DEFAULTS.threadsView,
reviewCommentsPreferred: bool(o.reviewCommentsPreferred, DEFAULTS.reviewCommentsPreferred),
autoMarkViewed: bool(o.autoMarkViewed, DEFAULTS.autoMarkViewed),
};
}
function load(): ReviewDiffSettings {
try {
const raw = globalThis.localStorage?.getItem(KEY);
return parseReviewDiffSettings(raw ? JSON.parse(raw) : null);
} catch {
return DEFAULTS;
}
}
export const useReviewDiffSettings = create<ReviewDiffSettings>(load);
export function setReviewDiffSettings(patch: Partial<ReviewDiffSettings>) {
useReviewDiffSettings.setState(patch);
try {
globalThis.localStorage?.setItem(KEY, JSON.stringify(useReviewDiffSettings.getState()));
} catch {
/* storage unavailable */
}
}