дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}` })));
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача