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

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

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

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

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

No files matched your search

+26 -7
Переглянути файл
@@ -24,6 +24,7 @@ import {
T,
} from "@/ui";
import { buildBranchRows, withExpanded, type BranchRow, type PopupAction } from "./branches";
import { branchPresentation, extensionPopupActions, useBranchWidgetExtensions } from "./branchWidgetExtensions";
import { branchMenu, commonBranchMenu, inRepo, tagMenu } from "./branchMenu";
import { loadFavoriteBranches, recentBranches, toggleFavoriteBranch, useFavoriteBranches } from "./branchOps";
import { inProgress, operationTitle, type InProgress } from "./operations";
@@ -65,8 +66,11 @@ export function BranchWidget() {
const other = useVcs((s) => (diverged && current && current !== s.repo ? (s.heads[current] ?? null) : null));
const otherOp = useVcs((s) => (current ? (s.operations[current] ?? null) : null));
const open = useBranchesPopup((s) => s.open);
const extensions = useBranchWidgetExtensions((s) => s.extensions);
const ref = useRef<HTMLButtonElement>(null);
if (!available || !selected) return null;
// Another feature presents the current branch (the GitHub pull request on it).
const presented = other ? null : branchPresentation(extensions);
const repo = diverged && current ? repoName(current) : null;
const branch: BranchInfo = other
? { head: other.head, headCommit: other.headCommit, upstream: null, ahead: 0, behind: 0, operation: otherOp }
@@ -81,6 +85,7 @@ export function BranchWidget() {
? tx`${branch.ahead} outgoing commits`
: "";
const tooltip = [
presented?.tooltip ?? "",
repo ? tx`Repository: ${repo}` : "",
tx`Git branch: ${head}`,
branch.upstream ? tx`Tracking ${branch.upstream}` : "",
@@ -100,12 +105,12 @@ export function BranchWidget() {
aria-expanded={open}
onClick={() => useBranchesPopup.setState({ open: !open })}
>
<Icon name={branch.head ? "branch" : "commit"} size={16} className={styles.icon} />
<Icon name={presented?.icon ?? (branch.head ? "branch" : "commit")} size={16} className={styles.icon} />
{repo && <span className={styles.repo}>{repo}</span>}
<span className={styles.name}>
{inProgress(branch.operation)
? operationTitle(inProgress(branch.operation)!, branch.head)
: headLabel(branch)}
: (presented?.text ?? headLabel(branch))}
</span>
{branch.operation && !inProgress(branch.operation) && (
<span className={styles.operation}>{branch.operation}</span>
@@ -282,6 +287,8 @@ const caretAt = (e: KeyboardEvent, where: "start" | "end") => {
function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | null>; onClose: () => void }) {
const multi = useVcs((s) => s.roots.length > 1);
const extensions = useBranchWidgetExtensions((s) => s.extensions);
const actions = useMemo(() => [...POPUP_ACTIONS, ...extensionPopupActions(extensions)], [extensions]);
// ⌥⌘N is also Inline (enabled while a popup is open over an editor): the search field handles it here.
useEffect(() => registerKeyClaim((_e, stroke) => isNewBranchStroke(stroke)), []);
return (
@@ -293,7 +300,11 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
aria-label="Git Branches"
className={clsx(styles.popup, multi && styles.wide)}
>
{multi ? <MultiRepoPanel onClose={onClose} /> : <BranchesPanel onClose={onClose} actions={POPUP_ACTIONS} />}
{multi ? (
<MultiRepoPanel onClose={onClose} actions={actions} />
) : (
<BranchesPanel onClose={onClose} actions={actions} />
)}
</Popup>
);
}
@@ -506,7 +517,7 @@ function SubMenu({
* each opening its own branches (on hover or →); then the branches they all have
* (acting on all of them) or, when their branches differ, the current repository's.
*/
function MultiRepoPanel({ onClose }: { onClose: () => void }) {
function MultiRepoPanel({ onClose, actions: popupActions }: { onClose: () => void; actions: readonly PopupAction[] }) {
const roots = useVcs((s) => s.roots);
const heads = useVcs((s) => s.heads);
const operations = useVcs((s) => s.operations);
@@ -568,9 +579,9 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) {
const actions = useMemo(
() => [
...operationActions(op, conflicts),
...POPUP_ACTIONS.map((a) => (a.id === "vcs.newBranch" ? { ...a, arg: { repos: roots } } : a)),
...popupActions.map((a) => (a.id === "vcs.newBranch" ? { ...a, arg: { repos: roots } } : a)),
],
[op, conflicts, roots],
[op, conflicts, roots, popupActions],
);
const rows = useMemo(
() =>
@@ -822,7 +833,15 @@ function BranchRowView({ row }: { row: BranchRow | Extract<RepoRow, { type: "act
const keys = shortcutLabel(row.action.id);
return (
<span className={styles.row} data-row-key={row.key}>
<Icon name={row.action.icon ?? "more"} size={14} className={styles.rowIcon} />
{row.action.checked === false ? (
<span className={styles.rowIcon} style={{ width: 14 }} />
) : (
<Icon
name={row.action.checked ? "check" : (row.action.icon ?? "more")}
size={14}
className={styles.rowIcon}
/>
)}
<HighlightedText className={styles.label} text={tx(row.action.label)} indices={row.matches} tone="accent" />
{keys && <span className={styles.keys}>{keys}</span>}
</span>
+156 -12
Переглянути файл
@@ -1,11 +1,20 @@
/** Diff viewer editor tab (side-by-side / unified) built on @codemirror/merge. */
import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import clsx from "clsx";
import { EditorView, keymap } from "@codemirror/view";
import { Prec, Text, type Extension } from "@codemirror/state";
import { history, historyKeymap } from "@codemirror/commands";
import { MergeView, getChunks, goToNextChunk, goToPreviousChunk, unifiedMergeView, Chunk } from "@codemirror/merge";
import {
MergeView,
getChunks,
getOriginalDoc,
goToNextChunk,
goToPreviousChunk,
unifiedMergeView,
uncollapseUnchanged,
Chunk,
} from "@codemirror/merge";
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
import { setFindOpen } from "@/editor/core/cm/find";
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
@@ -23,6 +32,7 @@ import {
ideaDiffConfig,
loadDiffOptions,
saveDiffOptions,
type DiffMode,
type DiffViewOptions,
type HighlightPolicy,
type IgnorePolicy,
@@ -41,6 +51,13 @@ import {
type DiffSide,
} from "./diffInput";
import { diffAnnotations } from "./diffAnnotations";
import {
createDiffViewerContributions,
type DiffPaneSide,
type DiffViewerContribution,
type DiffViewerHost,
type DiffViewerPane,
} from "./diffViewerProviders";
import { UnalignedPanes } from "./diffScroll";
import { groupOf } from "./changes";
import { inclusionGutter } from "./hunkInclusion";
@@ -96,6 +113,67 @@ export function mapLine(chunks: readonly Chunk[], line: number, from: Text, to:
return Math.max(0, Math.min(line + shift, to.lines - 1));
}
/** `chunks` seen from the right side (B ↔ A swapped), for `mapLine` from right to left. */
const swapped = (chunks: readonly Chunk[]) =>
chunks.map((c) => ({ fromA: c.fromB, toA: c.toB, fromB: c.fromA, toB: c.toA }) as unknown as Chunk);
/** Selects `pos` and scrolls it to the middle, first expanding the collapsed unchanged lines holding it. */
function revealPos(v: EditorView, pos: number) {
const block = v.lineBlockAt(pos);
const collapsed = v.state.doc.lineAt(block.from).number !== v.state.doc.lineAt(block.to).number;
v.dispatch({
selection: { anchor: pos },
effects: [
...(collapsed ? [uncollapseUnchanged.of(block.from)] : []),
EditorView.scrollIntoView(pos, { y: "center" }),
],
});
}
/** The panes as diff viewer contributions see them. */
function viewerHost(views: Views, data: DiffData, mode: DiffMode, focused: () => "a" | "b"): DiffViewerHost {
const docA = () => views.a?.state.doc ?? getOriginalDoc(views.b.state);
const chunks = () => getChunks(views.b.state)?.chunks ?? [];
const panes = (): DiffViewerPane[] =>
views.a
? [
{ side: "left", view: views.a },
{ side: "right", view: views.b },
]
: [{ side: "right", view: views.b }];
const mapSide = (from: DiffPaneSide, line: number) =>
from === "left"
? mapLine(chunks(), line, docA(), views.b.state.doc)
: mapLine(swapped(chunks()), line, views.b.state.doc, docA());
const locate = (side: DiffPaneSide, line: number) => {
const all = panes();
if (side === "left" && !views.a) return { pane: all[0]!, line: mapSide("left", line) };
const pane = all.find((p) => p.side === side)!;
return line >= 0 && line < pane.view.state.doc.lines ? { pane, line } : null;
};
return {
data,
mode,
panes,
doc: (side) => (side === "left" ? docA() : views.b.state.doc),
mapLine: mapSide,
locate,
reveal(side, line) {
const at = locate(side, line);
if (!at) return;
const v = at.pane.view;
revealPos(v, v.state.doc.line(at.line + 1).from);
v.focus();
},
focusedPane: () => {
const all = panes();
return focused() === "a" && views.a ? all[0]! : all[all.length - 1]!;
},
};
}
const NO_CONTRIBUTIONS: DiffViewerContribution[] = [];
/** Joins the open document of a file-backed side (working file, Compare Files); null for revisions / texts. */
async function attachSide(side: DiffSide, path: string): Promise<DocumentAttachment | null> {
const file = sideFile(side, path);
@@ -138,6 +216,21 @@ export function DiffEditor({ input }: { input: EditorInput }) {
const version = useVcs((s) => s.version);
const leftKey = sideKey(left);
const rightKey = sideKey(right);
const contextKey = JSON.stringify(data.context ?? null);
const dataRef = useRef(data);
dataRef.current = data;
/** Diff viewer providers' contributions for this diff (`registerDiffViewerProvider`). */
const [contribs, setContribs] = useState<DiffViewerContribution[]>(NO_CONTRIBUTIONS);
const viewerRef = useRef<DiffViewerHost | null>(null);
const [viewer, setViewer] = useState<DiffViewerHost | null>(null);
useEffect(() => {
const list = createDiffViewerContributions(dataRef.current);
setContribs(list.length ? list : NO_CONTRIBUTIONS);
return () => {
list.forEach((c) => c.dispose?.());
setContribs(NO_CONTRIBUTIONS);
};
}, [path, leftKey, rightKey, contextKey]);
const setOpts = (patch: Partial<DiffViewOptions>) =>
setOptsState((o) => {
@@ -267,6 +360,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
styles,
)
: [],
notes ? contribs.map((c) => c.extensions?.({ side: notes, unified: opts.mode !== "split" }) ?? []) : [],
];
let views: Views;
if (opts.mode === "split") {
@@ -379,9 +473,19 @@ export function DiffEditor({ input }: { input: EditorInput }) {
if (scrollTop != null) requestAnimationFrame(() => (view.scrollDOM.scrollTop = scrollTop));
}
viewsRef.current = views;
const built = viewerHost(views, dataRef.current, opts.mode, () => focusedSide.current);
const detach = contribs.map((c) => c.attach?.(built));
const destroyViews = views.destroy;
views.destroy = () => {
detach.forEach((d) => d?.());
destroyViews();
};
viewerRef.current = built;
setViewer(built);
setViewsGen((g) => g + 1);
requestAnimationFrame(updateChunkInfo);
}, [
contribs,
attached,
statics,
lang,
@@ -408,22 +512,32 @@ export function DiffEditor({ input }: { input: EditorInput }) {
[],
);
// Reveal the requested line (or the first / last difference) once.
const revealed = useRef(false);
// Reveal the requested line (or the first / last difference) once per request.
const revealed = useRef<string | null>(null);
const revealKey = data.line == null && !data.reveal ? null : `${data.line}:${data.lineSide}:${data.reveal}`;
useEffect(() => {
const v = viewsRef.current?.b;
if (!v || revealed.current || (data.line == null && !data.reveal) || !ready) return;
revealed.current = true;
let v = viewsRef.current?.b;
if (!v || revealKey === null || revealed.current === revealKey || !ready) return;
revealed.current = revealKey;
let at: number | undefined;
if (data.line != null) at = v.state.doc.line(Math.min(v.state.doc.lines, Math.max(1, data.line + 1))).from;
else {
if (data.line != null) {
let line = data.line;
if (data.lineSide === "left") {
const found = viewerRef.current?.locate("left", line);
if (found) [v, line] = [found.pane.view, found.line];
}
at = v.state.doc.line(Math.min(v.state.doc.lines, Math.max(1, line + 1))).from;
} else {
const chunks = getChunks(v.state)?.chunks ?? [];
at = (data.reveal === "last" ? chunks[chunks.length - 1] : chunks[0])?.fromB;
}
if (at === undefined) return;
v.dispatch({ selection: { anchor: at }, effects: EditorView.scrollIntoView(at, { y: "center" }) });
if (data.line != null) revealPos(v, at);
else v.dispatch({ selection: { anchor: at }, effects: EditorView.scrollIntoView(at, { y: "center" }) });
// A left-side line: that pane gets the focus, so navigation goes on from there.
if (v === viewsRef.current?.a) v.focus();
updateChunkInfo();
}, [ready, statics, data.line, data.reveal, updateChunkInfo]);
}, [ready, statics, revealKey, data.line, data.lineSide, data.reveal, updateChunkInfo]);
// IDEA: past the last (first) difference F7 (Shift+F7) first hints, then moves along the chain.
const [navHint, setNavHint] = useState<string | null>(null);
@@ -702,8 +816,27 @@ export function DiffEditor({ input }: { input: EditorInput }) {
disabled: opts.mode !== "split",
run: () => setOpts({ alignChanges: !opts.alignChanges }),
},
...contribs.flatMap((c) => {
const items = c.settingsItems?.() ?? [];
return items.length ? [{ kind: "separator" } as const, ...items] : [];
}),
]);
};
/** The panes' right-click menu: the contributions' items for the clicked line (none: the default menu). */
const onContextMenu = (e: MouseEvent) => {
const pane = viewerRef.current?.panes().find((p) => p.view.dom.contains(e.target as Node | null));
if (!pane) return;
const pos = pane.view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
const line = pane.view.state.doc.lineAt(pos).number - 1;
const items = contribs.flatMap((c) => {
const list = c.contextMenuItems?.({ side: pane.side, line }) ?? [];
return list.length ? [...list, { kind: "separator" } as const] : [];
});
if (!items.length) return;
e.preventDefault();
showContextMenu(e, items.slice(0, -1));
};
const viewerOf = () => viewerRef.current;
const nav = useRef<DiffNavigator>({
next: () => step(1),
prev: () => step(-1),
@@ -711,9 +844,18 @@ export function DiffEditor({ input }: { input: EditorInput }) {
applyChange,
file,
showSettings,
viewer: viewerOf,
});
useEffect(() => {
nav.current = { next: () => step(1), prev: () => step(-1), jumpToSource, applyChange, file, showSettings };
nav.current = {
next: () => step(1),
prev: () => step(-1),
jumpToSource,
applyChange,
file,
showSettings,
viewer: viewerOf,
};
});
useEffect(
@@ -853,6 +995,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
? tx`${chunkInfo.count} difference`
: tx`${chunkInfo.count} differences`}
</span>
{contribs.map((c, i) => c.Toolbar && <c.Toolbar key={i} host={viewer} />)}
<IconButton
icon="settings"
title="Settings"
@@ -921,6 +1064,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
hidden={!!error || !ready}
aria-label={diffTitle(data)}
role="region"
onContextMenu={onContextMenu}
/>
{navHint && (
<div className={styles.navHint} role="status">
+36
Переглянути файл
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
branchPresentation,
extensionPopupActions,
provideBranchWidgetExtension,
useBranchWidgetExtensions,
} from "../branchWidgetExtensions";
describe("branch widget extensions", () => {
it("presents the branch with the first extension's presentation and groups the popup actions", () => {
const a = provideBranchWidgetExtension({ id: "a", order: 200, actions: [{ id: "x", label: "X" }] });
const b = provideBranchWidgetExtension({
id: "b",
presentation: { text: "#1 on main", icon: "github-warning" },
actions: [
{ id: "y", label: "Y" },
{ id: "z", label: "Z", checked: true },
],
});
const m = useBranchWidgetExtensions.getState().extensions;
expect(branchPresentation(m)).toEqual({ text: "#1 on main", icon: "github-warning" });
expect(extensionPopupActions(m).map((x) => [x.id, x.group])).toEqual([
["y", "ext:b"],
["z", "ext:b"],
["x", "ext:a"],
]);
// Providing again replaces; a stale disposer leaves the replacement.
const b2 = provideBranchWidgetExtension({ id: "b", presentation: null });
b();
expect(useBranchWidgetExtensions.getState().extensions.get("b")).toBeDefined();
expect(branchPresentation(useBranchWidgetExtensions.getState().extensions)).toBeNull();
b2();
a();
expect(useBranchWidgetExtensions.getState().extensions.size).toBe(0);
});
});
+193
Переглянути файл
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { ContextMenuHost } from "@/ui";
import type { EditorInput } from "@/workbench/contributions";
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: rev === "A" ? "one\ntwo\nthree\n" : "one\nTWO\nthree\nfour\n" }),
},
};
});
import { DiffEditor } from "../DiffEditor";
import { registerDiffViewerProvider, type DiffViewerHost } from "../diffViewerProviders";
import { lineInlays, setLineInlays } from "../lineInlays";
import {
EMPTY_REVIEW_GUTTER,
canCreateComment,
clampToCommentable,
reviewGutter,
setReviewGutterState,
type ReviewGutterState,
} from "../reviewGutter";
const input = (context?: { kind: string }) =>
({
kind: "diff",
key: "diff:x",
data: { path: "/p/a.txt", left: { type: "rev", rev: "A" }, right: { type: "rev", rev: "B" }, context },
}) as unknown as EditorInput;
afterEach(cleanup);
describe("diff viewer providers", () => {
it("contribute pane extensions, the toolbar, settings and menu items to the diffs they apply to", async () => {
let host: DiffViewerHost | null = null;
const detach = vi.fn();
const dispose = vi.fn();
const off = registerDiffViewerProvider({
id: "test",
create: (data) =>
data.context?.kind === "test"
? {
extensions: (pane) => EditorView.editorAttributes.of({ "data-pane": `${pane.side}:${pane.unified}` }),
attach(h) {
host = h;
return detach;
},
Toolbar: () => <button type="button">Review</button>,
settingsItems: () => [{ id: "t", label: "Show Review Threads" }],
contextMenuItems: ({ side, line }) => [{ id: "c", label: `Comment ${side} ${line}` }],
dispose,
}
: null,
});
const { unmount } = render(
<>
<DiffEditor input={input({ kind: "test" })} />
<ContextMenuHost />
</>,
);
await screen.findByRole("button", { name: "Review" });
await waitFor(() => expect(host).not.toBeNull());
expect([...document.querySelectorAll("[data-pane]")].map((e) => e.getAttribute("data-pane"))).toEqual([
"left:false",
"right:false",
]);
const h = host as unknown as DiffViewerHost;
expect(h.panes().map((p) => p.side)).toEqual(["left", "right"]);
expect(h.doc("left").toString()).toBe("one\ntwo\nthree\n");
expect(h.mapLine("left", 2)).toBe(2);
expect(h.mapLine("right", 2)).toBe(2);
expect(h.locate("left", 1)).toMatchObject({ pane: { side: "left" }, line: 1 });
h.reveal("left", 1);
expect(h.focusedPane().side).toBe("left");
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("menuitem", { name: "Show Review Threads" })).toBeTruthy();
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
const line = [...document.querySelectorAll(".cm-merge-b .cm-line")].find((l) => l.textContent === "three")!;
fireEvent.contextMenu(line, { clientX: 1, clientY: 1 });
expect(await screen.findByRole("menuitem", { name: /^Comment right \d$/ })).toBeTruthy();
unmount();
expect(detach).toHaveBeenCalled();
expect(dispose).toHaveBeenCalled();
off();
});
it("leave other diffs alone", async () => {
const create = vi.fn(() => null);
const off = registerDiffViewerProvider({ id: "none", create });
render(<DiffEditor input={input()} />);
await waitFor(() => expect(document.querySelectorAll(".cm-merge-b .cm-line").length).toBeGreaterThan(0));
expect(create).toHaveBeenCalled();
expect(document.querySelector("[data-pane]")).toBeNull();
off();
});
});
describe("line inlays", () => {
it("render React content under lines and keep it across updates with the same key", async () => {
function Counter({ label }: { label: string }) {
const [n, setN] = useState(0);
return (
<button type="button" onClick={() => setN(n + 1)}>
{label} {n}
</button>
);
}
const parent = document.createElement("div");
document.body.append(parent);
const view = new EditorView({
state: EditorState.create({ doc: "a\nb\nc", extensions: lineInlays() }),
parent,
});
act(() => view.dispatch({ effects: setLineInlays.of([{ key: "k", line: 1, content: <Counter label="x" /> }]) }));
const btn = await screen.findByRole("button", { name: "x 0" });
expect(btn.closest(".cm-lineInlay")!.previousElementSibling?.textContent).toBe("b");
fireEvent.click(btn);
act(() => view.dispatch({ effects: setLineInlays.of([{ key: "k", line: 1, content: <Counter label="y" /> }]) }));
// Same key: the state (1 click) survives the new content.
expect(await screen.findByRole("button", { name: "y 1" })).toBeTruthy();
act(() => view.dispatch({ effects: setLineInlays.of([]) }));
expect(document.querySelector(".cm-lineInlay")).toBeNull();
view.destroy();
parent.remove();
});
});
describe("review gutter", () => {
const state = (over: Partial<ReviewGutterState> = {}): ReviewGutterState => ({
...EMPTY_REVIEW_GUTTER,
commentable: [
{ start: 0, end: 3 },
{ start: 5, end: 8 },
],
...over,
});
it("allows ranges within commentable lines only", () => {
expect(canCreateComment(state(), { start: 0, end: 2 })).toBe(true);
expect(canCreateComment(state(), { start: 2, end: 5 })).toBe(true);
expect(canCreateComment(state(), { start: 2, end: 4 })).toBe(false);
// A drag stops at the end of the commentable range it started in.
expect(clampToCommentable(state(), 1, 7)).toBe(2);
expect(clampToCommentable(state(), 6, 0)).toBe(5);
expect(clampToCommentable(state(), 5, 7)).toBe(7);
});
it("shows comment icons and outlines, and passes the clicks on", () => {
const toggleComments = vi.fn();
const parent = document.createElement("div");
document.body.append(parent);
const view = new EditorView({
state: EditorState.create({
doc: "0\n1\n2\n3\n4\n5\n6\n7\n8",
extensions: reviewGutter({ requestNewComment: vi.fn(), cancelNewComment: vi.fn(), toggleComments }),
}),
parent,
});
view.dispatch({
effects: setReviewGutterState.of(
state({
linesWithComments: new Set([1]),
outlines: [{ key: "n", range: { start: 5, end: 7 }, adjustable: false, tooltip: "fixed" }],
}),
),
});
const toggle = view.dom.querySelector<HTMLElement>(".cm-reviewControl-toggle")!;
expect(toggle).not.toBeNull();
fireEvent.mouseDown(toggle, { button: 0 });
expect(toggleComments).toHaveBeenCalledWith(1);
expect(view.dom.querySelectorAll(".cm-reviewRange")).toHaveLength(3);
const handles = [...view.dom.querySelectorAll<HTMLElement>(".cm-reviewHandle")];
expect(handles.map((h) => [h.title, h.dataset.disabled])).toEqual([
["fixed", "true"],
["fixed", "true"],
]);
view.destroy();
parent.remove();
});
});
+60
Переглянути файл
@@ -0,0 +1,60 @@
/**
* Extensions of the branch widget and its Branches popup contributed by other features
* (IDEA's `GitCurrentBranchPresenter` and the `Git.Experimental.Branch.Popup.Actions` group):
*
* - a presentation of the current branch (icon, text, tooltip) replacing the widget's own,
* as the GitHub plugin shows "#12 on feature" for a pull request's branch;
* - actions added to the popup's top, after its own (a separator before each extension's).
*
* An extension is provided under an id and replaced by providing it again (it follows its
* owner's state); the disposer removes it.
*
* const dispose = provideBranchWidgetExtension({ id: "github", presentation: { text: "#12 on feature" } });
*/
import { create } from "zustand";
import type { PopupAction } from "./branches";
export interface BranchPresentation {
/** Replaces the branch icon. */
icon?: string;
text: string;
/** Shown above the widget's own tooltip lines. */
tooltip?: string;
}
export interface BranchWidgetExtension {
id: string;
/** Lower first (presentation: the first one wins). Default 100. */
order?: number;
presentation?: BranchPresentation | null;
actions?: readonly PopupAction[];
}
export const useBranchWidgetExtensions = create<{ extensions: ReadonlyMap<string, BranchWidgetExtension> }>(() => ({
extensions: new Map(),
}));
/** Provides (or replaces) extension `ext.id`; returns its disposer. */
export function provideBranchWidgetExtension(ext: BranchWidgetExtension): () => void {
useBranchWidgetExtensions.setState((s) => ({ extensions: new Map(s.extensions).set(ext.id, ext) }));
return () =>
useBranchWidgetExtensions.setState((s) => {
if (s.extensions.get(ext.id) !== ext) return s;
const extensions = new Map(s.extensions);
extensions.delete(ext.id);
return { extensions };
});
}
const ordered = (m: ReadonlyMap<string, BranchWidgetExtension>) =>
[...m.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
/** The presentation replacing the widget's, if any. */
export function branchPresentation(m: ReadonlyMap<string, BranchWidgetExtension>): BranchPresentation | null {
return ordered(m).find((e) => e.presentation)?.presentation ?? null;
}
/** The extensions' popup actions, each extension in a group of its own. */
export function extensionPopupActions(m: ReadonlyMap<string, BranchWidgetExtension>): PopupAction[] {
return ordered(m).flatMap((e) => (e.actions ?? []).map((a) => ({ ...a, group: `ext:${e.id}` })));
}
+2
Переглянути файл
@@ -11,6 +11,8 @@ export interface PopupAction {
arg?: unknown;
/** A separator goes between actions of different groups (Update / Commit / Push | New Branch / Checkout…). */
group?: string;
/** A toggle: shown with a check mark when on. */
checked?: boolean;
}
export type BranchGroup = "recent" | "local" | "remote";
+8 -1
Переглянути файл
@@ -34,8 +34,10 @@ export interface DiffData {
path: string;
left: DiffSide;
right: DiffSide;
/** Reveal this 0-based line of the right side. */
/** Reveal this 0-based line (of the right side, unless `lineSide` says otherwise). */
line?: number;
/** The side `line` is on. */
lineSide?: "left" | "right";
/** Tab title instead of the default "name (left ↔ right)". */
title?: string;
/** Notes shown under lines (unified mode shows the right side's only). */
@@ -47,6 +49,11 @@ export interface DiffData {
chain?: DiffData[];
/** Select the first or last difference once shown (moving along the chain). */
reveal?: "first" | "last";
/**
* What the diff shows, for diff viewer providers (`registerDiffViewerProvider`), e.g. a
* pull request's file: `{ kind: "github.pullRequest", number: 12, path: "src/a.ts" }`. Plain JSON.
*/
context?: { kind: string; [key: string]: unknown };
}
export const DIFF_KIND = "diff";
+3
Переглянути файл
@@ -1,4 +1,5 @@
/** The focused diff editor's chunk navigation, for actions that must not load the diff editor. */
import type { DiffViewerHost } from "./diffViewerProviders";
export interface DiffNavigator {
next(): void;
@@ -10,6 +11,8 @@ export interface DiffNavigator {
file(dir: 1 | -1): void;
/** The viewer's settings as a popup menu (⇧⌘D). */
showSettings(): void;
/** The built panes, for diff viewer contributions' actions (null while loading). */
viewer?(): DiffViewerHost | null;
}
let focused: DiffNavigator | null = null;
+95
Переглянути файл
@@ -0,0 +1,95 @@
/**
* Diff viewer extension point (IDEA's `DiffExtension.onViewerCreated`): a provider looks at
* the diff being shown (`DiffData`, usually its `context`) and may contribute CodeMirror
* extensions to each pane (gutters, inlays), toolbar items, viewer settings and editor
* context menu items. Features use it for code review (pull request threads in the diff).
*
* registerDiffViewerProvider({
* id: "github.review",
* create: (data) => (data.context?.kind === "github.pullRequest" ? new ReviewDiff(data) : null),
* });
*
* A contribution lives as long as the diff tab shows that diff (`context`, path and sides);
* `attach` runs each time the viewer (re)builds its panes (mode / option changes).
*/
import type { ComponentType } from "react";
import type { Extension, Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import type { MenuItem } from "@/ui";
import type { DiffData } from "./diffInput";
import type { DiffMode } from "./diffOptions";
export type DiffPaneSide = "left" | "right";
export interface DiffViewerPane {
side: DiffPaneSide;
view: EditorView;
}
/** Where a pane's extensions go: its side and whether it is the unified viewer's only pane. */
export interface DiffPaneInfo {
side: DiffPaneSide;
/** The unified viewer: the right side's document with the left side's deleted lines inline. */
unified: boolean;
}
/** The built viewer, as contributions see it. */
export interface DiffViewerHost {
readonly data: DiffData;
readonly mode: DiffMode;
/** Split: left and right; unified: one pane (`right`). */
panes(): readonly DiffViewerPane[];
/** Text of a side. */
doc(side: DiffPaneSide): Text;
/** The 0-based line of the other side matching `line` of `from` (across the changes between them). */
mapLine(from: DiffPaneSide, line: number): number;
/** The pane showing `line` of `side` and its line there (unified: left lines map to the right side). */
locate(side: DiffPaneSide, line: number): { pane: DiffViewerPane; line: number } | null;
/** Moves the caret to `line` of `side` and scrolls it to the middle, focusing that pane. */
reveal(side: DiffPaneSide, line: number): void;
/** The pane that had focus last. */
focusedPane(): DiffViewerPane;
}
export interface DiffViewerContribution {
/** Extensions of one pane, included each time the panes are built. */
extensions?(pane: DiffPaneInfo): Extension;
/** The panes were built; the returned function runs before they are destroyed. */
attach?(host: DiffViewerHost): (() => void) | void;
/** Rendered at the end of the diff toolbar (before the settings button). */
Toolbar?: ComponentType<{ host: DiffViewerHost | null }>;
/** Items appended to the viewer settings popup (⇧⌘D, the gear). */
settingsItems?(): MenuItem[];
/** Items of the panes' right-click menu for `line` (0-based) of `side`. */
contextMenuItems?(at: { side: DiffPaneSide; line: number }): MenuItem[];
dispose?(): void;
}
export interface DiffViewerProvider {
id: string;
/** A contribution for the diff, or null when the provider does not apply. */
create(data: DiffData): DiffViewerContribution | null;
}
const providers = new Map<string, DiffViewerProvider>();
export function registerDiffViewerProvider(provider: DiffViewerProvider): () => void {
providers.set(provider.id, provider);
return () => {
if (providers.get(provider.id) === provider) providers.delete(provider.id);
};
}
/** The contributions of every provider that applies to `data`. */
export function createDiffViewerContributions(data: DiffData): DiffViewerContribution[] {
const out: DiffViewerContribution[] = [];
for (const p of providers.values()) {
try {
const c = p.create(data);
if (c) out.push(c);
} catch (e) {
console.error(`diff viewer provider ${p.id} failed`, e);
}
}
return out;
}
+102
Переглянути файл
@@ -0,0 +1,102 @@
/**
* Component inlays under lines (IDEA's block inlays with Swing components, as code review
* threads use them): each inlay is a CodeMirror block widget holding a React root. Inlays
* are replaced as a whole with `setLineInlays`; one with the same key keeps its DOM (and
* React state) and is re-rendered with the new content.
*
* view.dispatch({ effects: setLineInlays.of([{ key: thread.id, line: 41, content: <Thread … /> }]) });
*/
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view";
export interface LineInlay {
/** Stable identity: the same key keeps the DOM and React state. */
key: string;
/** 0-based line the inlay shows under. */
line: number;
content: ReactNode;
}
/** Replaces the inlays of a view. */
export const setLineInlays = StateEffect.define<readonly LineInlay[]>();
interface Mounted {
root: Root;
observer: ResizeObserver | null;
}
const mounted = new WeakMap<HTMLElement, Mounted>();
class InlayWidget extends WidgetType {
constructor(readonly inlay: LineInlay) {
super();
}
override eq(other: WidgetType): boolean {
return (
other instanceof InlayWidget && other.inlay.key === this.inlay.key && other.inlay.content === this.inlay.content
);
}
override toDOM(view: EditorView): HTMLElement {
const dom = document.createElement("div");
dom.className = "cm-lineInlay";
dom.dataset.inlay = this.inlay.key;
const root = createRoot(dom);
root.render(this.inlay.content);
// The content grows and shrinks (editors, folded replies): CodeMirror re-measures heights.
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => view.requestMeasure());
observer?.observe(dom);
mounted.set(dom, { root, observer });
return dom;
}
override updateDOM(dom: HTMLElement): boolean {
const m = mounted.get(dom);
if (!m || dom.dataset.inlay !== this.inlay.key) return false;
m.root.render(this.inlay.content);
return true;
}
override destroy(dom: HTMLElement): void {
const m = mounted.get(dom);
if (!m) return;
mounted.delete(dom);
m.observer?.disconnect();
// Not while React may be rendering (a widget can go away during a dispatch from an event handler).
queueMicrotask(() => m.root.unmount());
}
override get estimatedHeight(): number {
return 60;
}
override ignoreEvent(): boolean {
return true;
}
}
function build(state: EditorState, inlays: readonly LineInlay[]): DecorationSet {
const ranges = inlays
.filter((i) => i.line >= 0 && i.line < state.doc.lines)
.map((i, order) => ({ i, order, pos: state.doc.line(i.line + 1).to }))
.sort((a, b) => a.pos - b.pos || a.order - b.order)
.map(({ i, pos }) => Decoration.widget({ widget: new InlayWidget(i), block: true, side: 1 }).range(pos));
return Decoration.set(ranges);
}
// A block formatting context: the content's margins count in the height CodeMirror measures.
// The scroller is the size container, for inlay content to stay within the pane's width (`cqw`).
const theme = EditorView.baseTheme({
".cm-scroller": { containerType: "inline-size" },
".cm-lineInlay": { display: "flow-root" },
});
/** The inlay field of a view, starting with `initial`. */
export function lineInlays(initial: readonly LineInlay[] = []): Extension {
const field = StateField.define<DecorationSet>({
create: (state) => build(state, initial),
update(value, tr) {
for (const e of tr.effects) if (e.is(setLineInlays)) return build(tr.state, e.value);
return tr.docChanged ? value.map(tr.changes) : value;
},
provide: (f) => EditorView.decorations.from(f),
});
return [field, theme];
}
+363
Переглянути файл
@@ -0,0 +1,363 @@
/**
* Code review gutter controls (collaboration-tools' `CodeReviewEditorGutterControlsRenderer`
* and the comment range outline): a 16px column showing
*
* - a comment icon on lines with threads (click: show / hide them),
* - on the hovered commentable line, the "+" (`InlineAdd`, its hover variant over the column;
* "Leave a comment") — or the remove icon ("Discard a comment") where a new comment is open;
* dragging the "+" over further commentable lines selects a range for a multi-line comment,
* - with a text selection, the "+" on its last line (a comment on the selected lines),
* - outlines of new comments' ranges, with drag handles to adjust them
* ("Drag to select several lines for commenting", or why it can't be adjusted).
*
* The owner pushes the state with `setReviewGutterState` and handles the requests.
* Lines are 0-based; ranges are inclusive.
*/
import { StateEffect, StateField, type EditorState, type Extension, type Range } from "@codemirror/state";
import { Decoration, EditorView, GutterMarker, ViewPlugin, gutter, type DecorationSet } from "@codemirror/view";
import { tx } from "@/platform/i18n";
import { iconMarkup } from "@/ui";
export interface LineRange {
start: number;
end: number;
}
/** The range of a comment being written. */
export interface ReviewOutline {
key: string;
range: LineRange;
/** Its ends can be dragged. */
adjustable: boolean;
/** Tooltip of the handles: how to adjust, or why it can't be. */
tooltip: string;
}
export interface ReviewGutterState {
linesWithComments: ReadonlySet<number>;
linesWithNewComments: ReadonlySet<number>;
/** Lines whose inlays are shown (there is room for a second icon under the line). */
linesWithInlays: ReadonlySet<number>;
/** Commentable lines, as half-open ranges `[start, end)`. */
commentable: readonly { start: number; end: number }[];
outlines: readonly ReviewOutline[];
}
export interface ReviewGutterHandlers {
requestNewComment(range: LineRange): void;
cancelNewComment(line: number): void;
toggleComments(line: number): void;
adjustRange?(key: string, range: LineRange): void;
}
export const EMPTY_REVIEW_GUTTER: ReviewGutterState = {
linesWithComments: new Set(),
linesWithNewComments: new Set(),
linesWithInlays: new Set(),
commentable: [],
outlines: [],
};
export const setReviewGutterState = StateEffect.define<ReviewGutterState>();
export function isLineCommentable(state: ReviewGutterState, line: number): boolean {
return state.commentable.some((r) => line >= r.start && line < r.end);
}
/** Both ends of `range` are commentable (`canCreateComment`); a pane shows one side, so they share it. */
export function canCreateComment(state: ReviewGutterState, range: LineRange): boolean {
return isLineCommentable(state, range.start) && isLineCommentable(state, range.end);
}
/** `head` limited to the commentable lines next to `anchor` (a drag stays in one commentable range). */
export function clampToCommentable(state: ReviewGutterState, anchor: number, head: number): number {
const dir = head >= anchor ? 1 : -1;
let line = anchor;
while (line !== head && isLineCommentable(state, line + dir)) line += dir;
return line;
}
const sorted = (a: number, b: number): LineRange => ({ start: Math.min(a, b), end: Math.max(a, b) });
type Drag =
| { kind: "new"; anchor: number; head: number }
| { kind: "adjust"; key: string; fixed: number; head: number };
interface GutterData {
state: ReviewGutterState;
hover: { line: number; column: boolean } | null;
drag: Drag | null;
deco: DecorationSet;
}
const setHover = StateEffect.define<GutterData["hover"]>();
const setDrag = StateEffect.define<Drag | null>();
function dragRange(d: Drag): LineRange {
return d.kind === "new" ? sorted(d.anchor, d.head) : sorted(d.fixed, d.head);
}
function decorations(doc: EditorState["doc"], data: Omit<GutterData, "deco">): DecorationSet {
const ranges: LineRange[] = data.state.outlines
.filter((o) => !(data.drag?.kind === "adjust" && data.drag.key === o.key))
.map((o) => o.range);
if (data.drag) ranges.push(dragRange(data.drag));
const out: Range<Decoration>[] = [];
for (const r of ranges) {
for (let l = Math.max(0, r.start); l <= Math.min(r.end, doc.lines - 1); l++) {
const cls = ["cm-reviewRange", l === r.start && "cm-reviewRange-first", l === r.end && "cm-reviewRange-last"];
out.push(Decoration.line({ class: cls.filter(Boolean).join(" ") }).range(doc.line(l + 1).from));
}
}
return Decoration.set(out, true);
}
const field = StateField.define<GutterData>({
create: (s) => {
const base = { state: EMPTY_REVIEW_GUTTER, hover: null, drag: null };
return { ...base, deco: decorations(s.doc, base) };
},
update(value, tr) {
let next = value;
for (const e of tr.effects) {
if (e.is(setReviewGutterState)) next = { ...next, state: e.value };
else if (e.is(setHover)) next = { ...next, hover: e.value };
else if (e.is(setDrag)) next = { ...next, drag: e.value };
}
if (next === value && !tr.docChanged) return value;
const changedRanges = next.state !== value.state || next.drag !== value.drag || tr.docChanged;
return { ...next, deco: changedRanges ? decorations(tr.state.doc, next) : value.deco };
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
type Action = "toggle" | "add" | "cancel";
/** The last line of a non-empty selection, and the selected range. */
function selectionRange(state: EditorState): LineRange | null {
const sel = state.selection.main;
if (sel.empty) return null;
return { start: state.doc.lineAt(sel.from).number - 1, end: state.doc.lineAt(sel.to).number - 1 };
}
/** The icons of `line`, top to bottom (`LogicalLineData.getActions`). */
function actionsOf(state: EditorState, line: number): Action[] {
const data = state.field(field);
const s = data.state;
const out: Action[] = [];
const hovered = data.hover?.line === line && !data.drag;
const selected = selectionRange(state);
const last = state.doc.lines - 1;
const lastBlank = line === last && state.doc.line(line + 1).length === 0;
if (s.linesWithComments.has(line)) out.push("toggle");
if (!lastBlank && isLineCommentable(s, line)) {
if (s.linesWithNewComments.has(line)) {
if (hovered) out.push("cancel");
} else if (selected && line === selected.end) out.push("add");
else if (hovered && (!selected || line < selected.start || line > selected.end)) out.push("add");
}
return out;
}
class ControlsMarker extends GutterMarker {
constructor(
readonly line: number,
readonly actions: readonly Action[],
readonly columnHovered: boolean,
readonly room: boolean,
readonly handles: readonly { key: string; end: "start" | "end"; adjustable: boolean; tooltip: string }[],
) {
super();
}
override eq(other: GutterMarker): boolean {
return (
other instanceof ControlsMarker &&
other.line === this.line &&
other.columnHovered === this.columnHovered &&
other.room === this.room &&
other.actions.join() === this.actions.join() &&
JSON.stringify(other.handles) === JSON.stringify(this.handles)
);
}
override toDOM(view: EditorView): Node {
const box = document.createElement("div");
box.className = "cm-reviewControls";
box.dataset.line = String(this.line);
box.style.setProperty("--cm-review-line", `${view.defaultLineHeight}px`);
// A second icon goes under the line, over its inlays, only when they leave room (`layoutActions`).
const shown = this.room ? this.actions : this.actions.slice(0, 1);
shown.forEach((a, i) => {
const btn = document.createElement("span");
btn.className = `cm-reviewControl cm-reviewControl-${a}`;
btn.dataset.action = a;
btn.style.top = `calc(${i} * var(--cm-review-line, 1.4em))`;
const icon =
a === "toggle"
? "review-comment"
: a === "cancel"
? "minus"
: this.columnHovered
? "inline-add-hover"
: "inline-add";
btn.innerHTML = iconMarkup(icon, 16) ?? "";
if (a === "add") btn.title = tx("Leave a comment");
else if (a === "cancel") btn.title = tx("Discard a comment");
box.append(btn);
});
for (const h of this.handles) {
const handle = document.createElement("span");
handle.className = `cm-reviewHandle cm-reviewHandle-${h.end}`;
if (h.adjustable) {
handle.dataset.action = `adjust-${h.end}`;
handle.dataset.key = h.key;
}
handle.dataset.disabled = h.adjustable ? undefined : "true";
handle.title = h.tooltip;
box.append(handle);
}
return box;
}
}
const spacer = new (class extends GutterMarker {
override toDOM() {
const el = document.createElement("div");
el.className = "cm-reviewControls";
return el;
}
})();
function markerFor(state: EditorState, line: number): GutterMarker | null {
const data = state.field(field);
const actions = actionsOf(state, line);
const handles = data.state.outlines.flatMap((o) =>
(["start", "end"] as const)
.filter((end) => (end === "start" ? o.range.start : o.range.end) === line)
.map((end) => ({ key: o.key, end, adjustable: o.adjustable, tooltip: o.tooltip })),
);
if (!actions.length && !handles.length) return null;
const room = data.state.linesWithInlays.has(line);
return new ControlsMarker(line, actions, data.hover?.line === line && data.hover.column, room, handles);
}
function lineAtY(view: EditorView, clientY: number): number | null {
const y = clientY - view.documentTop;
if (y < 0 || y > view.contentHeight) return null;
const block = view.lineBlockAtHeight(y);
return view.state.doc.lineAt(block.from).number - 1;
}
/** The review gutter of a pane; `handlers` receive the user's requests. */
export function reviewGutter(handlers: ReviewGutterHandlers): Extension {
const startDrag = (view: EditorView, drag: Drag) => {
view.dispatch({ effects: setDrag.of(drag) });
const move = (e: MouseEvent) => {
const line = lineAtY(view, e.clientY);
const cur = view.state.field(field).drag;
if (line === null || !cur) return;
const s = view.state.field(field).state;
const head = clampToCommentable(s, cur.kind === "new" ? cur.anchor : cur.fixed, line);
if (head !== cur.head) view.dispatch({ effects: setDrag.of({ ...cur, head }) });
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up, true);
const cur = view.state.field(field).drag;
view.dispatch({ effects: setDrag.of(null) });
if (!cur) return;
const range = dragRange(cur);
if (cur.kind === "new") handlers.requestNewComment(range);
else handlers.adjustRange?.(cur.key, range);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up, true);
};
const column = gutter({
class: "cm-reviewGutter",
lineMarker: (view, block) => markerFor(view.state, view.state.doc.lineAt(block.from).number - 1),
lineMarkerChange: (u) => u.startState.field(field) !== u.state.field(field) || u.selectionSet,
initialSpacer: () => spacer,
domEventHandlers: {
mousedown(view, block, event) {
const target = (event.target as HTMLElement | null)?.closest<HTMLElement>("[data-action]");
const action = target?.dataset.action;
if (!action || (event as MouseEvent).button !== 0) return false;
event.preventDefault();
const marked = Number(target.closest<HTMLElement>("[data-line]")?.dataset.line);
const line = Number.isInteger(marked) ? marked : view.state.doc.lineAt(block.from).number - 1;
const s = view.state.field(field).state;
if (action === "toggle") handlers.toggleComments(line);
else if (action === "cancel") handlers.cancelNewComment(line);
else if (action === "add") {
const selected = selectionRange(view.state);
if (selected && selected.end === line && canCreateComment(s, selected)) handlers.requestNewComment(selected);
else startDrag(view, { kind: "new", anchor: line, head: line });
} else if (action.startsWith("adjust-") && target.dataset.key) {
const outline = s.outlines.find((o) => o.key === target.dataset.key);
if (!outline) return true;
const fixed = action === "adjust-start" ? outline.range.end : outline.range.start;
startDrag(view, { kind: "adjust", key: outline.key, fixed, head: line });
}
return true;
},
},
});
const hover = ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
view.dom.addEventListener("mousemove", this.move);
view.dom.addEventListener("mouseleave", this.leave);
}
move = (e: MouseEvent) => {
const line = lineAtY(this.view, e.clientY);
const col = !!(e.target as HTMLElement | null)?.closest?.(".cm-reviewGutter");
const next = line === null ? null : { line, column: col };
const cur = this.view.state.field(field).hover;
if (cur?.line !== next?.line || cur?.column !== next?.column)
this.view.dispatch({ effects: setHover.of(next) });
};
leave = () => {
if (this.view.state.field(field).hover) this.view.dispatch({ effects: setHover.of(null) });
};
destroy() {
this.view.dom.removeEventListener("mousemove", this.move);
this.view.dom.removeEventListener("mouseleave", this.leave);
}
},
);
return [field, column, hover, theme];
}
const theme = EditorView.baseTheme({
".cm-reviewGutter": { width: "16px" },
".cm-reviewGutter .cm-gutterElement": { position: "relative", overflow: "visible" },
".cm-reviewControls": { position: "relative", width: "16px", height: "100%" },
".cm-reviewControl": {
position: "absolute",
left: "0",
width: "16px",
height: "var(--cm-review-line, 1.4em)",
display: "flex",
alignItems: "center",
cursor: "pointer",
},
".cm-reviewHandle": {
position: "absolute",
left: "2px",
width: "12px",
height: "4px",
borderRadius: "2px",
background: "var(--focus-ring, #3574f0)",
cursor: "ns-resize",
},
".cm-reviewHandle[data-disabled]": { opacity: "0.4", cursor: "default" },
".cm-reviewHandle-start": { top: "-2px" },
".cm-reviewHandle-end": { top: "calc(var(--cm-review-line, 1.4em) - 2px)" },
".cm-reviewRange": {
boxShadow: "inset 2px 0 0 var(--focus-ring, #3574f0)",
background: "color-mix(in srgb, var(--focus-ring, #3574f0) 8%, transparent)",
},
});