mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 00:07:29 +00:00
feat(github): pull request tool window, timeline and review flow (#477)
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication. - List with search, quick filters and paging; a tab per pull request and a New Pull Request tab. - Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen. - Timeline editor: comments, review threads, reactions and suggested changes. - Review in the diff viewer and, for the checked-out branch, in the editor (review mode). - New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands. - CSP: img-src allows https://avatars.githubusercontent.com for avatars. Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane. Co-authored-by: imacross <30753807+imacross@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user