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