feat(editor): JetBrains-style line-number gutter context menu

Right-click on the gutter now shows Annotate with Git Blame, Add/Remove
Bookmark, Soft-Wrap, Configure Soft Wraps, an Appearance submenu and
Configure Gutter Icons, contributed through a new "editorGutter" menu
group. A breakpoint line still opens the breakpoint popover.
Цей коміт міститься в:
l0ng-ai committed 2026-10-24 11:38:08 +08:00
1 джерело f5e8d77dcf
коміт be5a6f62ad
6 files changed
+126 -64

No files matched your search

+51
Переглянути файл
@@ -0,0 +1,51 @@
/** The line-number gutter's context menu, IDEA's EditorGutterPopupMenu. */
import { executeAction } from "@/platform/actions";
import { editorPrefs } from "@/platform/editorSettings";
import { call, commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { menuGroupItems } from "@/workbench/contributions";
import type { MenuItem } from "@/ui";
import type { EditorSlot } from "./model/slot";
/** Whether a right-click on `target` belongs to the gutter menu (contributed gutters with their own menu handle theirs). */
export function inGutterMenuArea(target: EventTarget | null): boolean {
const t = target as HTMLElement | null;
return !!t?.closest?.(".cm-gutters") && !t.closest(".cm-vcs-blame");
}
async function setEditorSetting(key: string, value: unknown) {
useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) });
}
function toggle(label: string, key: string, on: boolean): MenuItem {
return { label, checked: on, run: () => void setEditorSetting(key, !on) };
}
function appearanceItems(): MenuItem[] {
const p = editorPrefs();
const whitespace = !!useProject.getState().settings?.editor?.showWhitespace;
return [
toggle("Show Line Numbers", "appearance.lineNumbers", p.appearance.lineNumbers),
toggle("Show Gutter Icons", "showGutterIcons", p.showGutterIcons),
toggle("Show Folding Outline", "folding.showOutline", p.folding.showOutline),
toggle("Show Indent Guides", "appearance.indentGuides", p.appearance.indentGuides),
toggle("Show Whitespaces", "showWhitespace", whitespace),
toggle("Show Method Separators", "appearance.methodSeparators", p.appearance.methodSeparators),
toggle("Show Breadcrumbs", "breadcrumbs.show", p.breadcrumbs.show),
];
}
const openSettings = (page: string) => () => void executeAction("file.settings", page);
export function gutterMenuItems(slot: EditorSlot, line: number): MenuItem[] {
const model = slot.model;
return [
// Contributed groups (Annotate with Git Blame, bookmarks), each followed by a separator.
...menuGroupItems("editorGutter", { paths: [model.path], text: slot.currentState().doc.toString(), line }),
{ label: "Soft-Wrap", checked: model.softWrapOn(), run: () => model.toggleSoftWrap() },
{ label: "Configure Soft Wraps…", run: openSettings("editor.general") },
{ kind: "separator" },
{ label: "Appearance", submenu: appearanceItems() },
{ label: "Configure Gutter Icons…", run: openSettings("editor.general.gutterIcons") },
];
}
+15 -1
Переглянути файл
@@ -17,9 +17,11 @@ import {
type ChangeSet,
type TransactionSpec,
} from "@codemirror/state";
import { EditorView, keymap, type ViewUpdate } from "@codemirror/view";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import { _addEditor, _removeEditor, documentSynced, isExternalChange, type EditorDocContext, type TextEditorHandle } from "@/editor/api";
import { showContextMenu } from "@/ui";
import { baseExtensions, compartments, contributionExtensions } from "../cm/setup";
import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu";
import type { DocModel } from "./document";
export interface SlotHost {
@@ -107,6 +109,18 @@ export class EditorSlot {
return true;
},
}),
ViewPlugin.define((view) => {
// Gutters sit outside the content DOM. Gutter extensions with their own menu (breakpoints,
// annotations) handle the event first and prevent its default.
const onContextMenu = (e: MouseEvent) => {
if (e.defaultPrevented || !inGutterMenuArea(e.target)) return;
e.preventDefault();
const block = view.lineBlockAtHeight(e.clientY - view.documentTop);
showContextMenu(e, gutterMenuItems(this, view.state.doc.lineAt(block.from).number - 1));
};
view.dom.addEventListener("contextmenu", onContextMenu);
return { destroy: () => view.dom.removeEventListener("contextmenu", onContextMenu) };
}),
];
}
+7 -52
Переглянути файл
@@ -6,13 +6,11 @@
import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, GutterMarker, ViewPlugin, lineNumberMarkers, type ViewUpdate } from "@codemirror/view";
import { editorsFor, mirroredChange, remoteChange, useEditorRegistry, type EditorDocContext } from "@/editor/api";
import { showContextMenu } from "@/ui";
import { isConditional, mapLines, type BreakpointEntry, type BreakpointStatus } from "../breakpoints/model";
import {
addBreakpoint,
breakpointsOf,
setFileBreakpoints,
toggleTemporaryBreakpoint,
updateBreakpoint,
useBreakpoints,
} from "../breakpoints/store";
@@ -21,14 +19,11 @@ import {
removeSpecial,
setSpecialLines,
specialAt,
toggleFieldAt,
toggleMethodAt,
updateSpecial,
useSpecialBreakpoints,
type SpecialBreakpoint,
} from "../breakpoints/special";
import { toggleBreakpointSmart } from "../breakpoints/toggle";
import { methodAt, fieldAt } from "../breakpoints/symbols";
import { sessionsRunning, useDebug } from "../state";
import { openBreakpointPopover } from "../ui/popoverStore";
import styles from "./editor.module.css";
@@ -172,7 +167,8 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
}),
];
view.dom.addEventListener("mousedown", this.onMouseDown);
view.dom.addEventListener("contextmenu", this.onContextMenu);
// Inside view.dom, so this runs before the editor's gutter menu listener there.
view.scrollDOM.addEventListener("contextmenu", this.onContextMenu);
}
scheduleRebuild() {
@@ -234,59 +230,18 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
if (!inBreakpointArea(e)) return;
const line = lineAtEvent(this.view, e);
if (line == null) return;
e.preventDefault();
e.stopPropagation();
const at = { x: e.clientX, y: e.clientY };
// Other lines get the editor's gutter menu, like IDEA.
const bp = specialAt(path).find((b) => b.line === line) ?? breakpointsOf(path).find((b) => b.line === line);
if (bp) {
openBreakpointPopover(path, bp.id, at);
return;
}
if (!canPlaceBreakpoint(ctx.language)) return;
const text = this.view.state.doc.toString();
const method = methodAt(ctx.language, text, line);
const field = fieldAt(ctx.language, text, line);
showContextMenu(at, [
{ label: "Toggle Line Breakpoint", icon: "breakpoint", run: () => addBreakpoint(path, line) },
{
label: "Toggle Temporary Line Breakpoint",
icon: "breakpoint",
run: () => toggleTemporaryBreakpoint(path, line),
},
...(method
? [
{
label: `Toggle Method Breakpoint (${method.label})`,
icon: "breakpoint-method",
run: () => void toggleMethodAt(path, line, method.name),
},
]
: []),
...(field
? [
{
label: `Toggle Field Watchpoint (${field.label})`,
icon: "breakpoint-field",
run: () => void toggleFieldAt(path, line, field),
},
]
: []),
{
label: "Add Conditional Breakpoint…",
run: () => openBreakpointPopover(path, addBreakpoint(path, line).id, at),
},
{
label: "Add Logpoint…",
run: () => openBreakpointPopover(path, addBreakpoint(path, line).id, at, true),
},
]);
if (!bp) return;
e.preventDefault();
openBreakpointPopover(path, bp.id, { x: e.clientX, y: e.clientY });
};
destroy() {
this.destroyed = true;
this.unsubs.forEach((u) => u());
this.view.dom.removeEventListener("mousedown", this.onMouseDown);
this.view.dom.removeEventListener("contextmenu", this.onContextMenu);
this.view.scrollDOM.removeEventListener("contextmenu", this.onContextMenu);
}
},
);
+27 -5
Переглянути файл
@@ -5,12 +5,14 @@
*/
import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { executeAction, registerActions } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { bookmarkGutter } from "./bookmarks/gutter";
import {
hasBookmark,
loadBookmarks,
renameBookmarks,
toggleBookmark,
@@ -46,7 +48,8 @@ function closeAllPopups(except?: PopupKind) {
if (except !== "everywhere") hideSearchEverywhere();
if (except !== "recent") hideRecentPopup();
if (except !== "find") hideFindPopup();
if (except !== "fileStructure" && useFileStructurePopup.getState().open) useFileStructurePopup.setState({ open: false });
if (except !== "fileStructure" && useFileStructurePopup.getState().open)
useFileStructurePopup.setState({ open: false });
}
function jumpBookmark(dir: 1 | -1) {
@@ -59,7 +62,9 @@ function jumpBookmark(dir: 1 | -1) {
const cur = `${path ?? ""}\u0000${String(line).padStart(9, "0")}`;
const sorted = order.sort((a, b) => (a.k < b.k ? -1 : a.k > b.k ? 1 : 0));
const next =
dir === 1 ? (sorted.find((x) => x.k > cur) ?? sorted[0]) : ([...sorted].reverse().find((x) => x.k < cur) ?? sorted[sorted.length - 1]);
dir === 1
? (sorted.find((x) => x.k > cur) ?? sorted[0])
: ([...sorted].reverse().find((x) => x.k < cur) ?? sorted[sorted.length - 1]);
if (next) void openLocation(next.b.path, { line: next.b.line, col: 0 });
}
@@ -113,6 +118,22 @@ export function activate(): void | (() => void) {
order: 50,
extensions: (ctx) => bookmarkGutter(ctx.path),
}),
registerMenuGroup({
id: "search.bookmarks",
menus: ["editorGutter"],
order: 20,
items: (_menu, { paths: [path], line }) => {
if (!path || line == null) return [];
const on = hasBookmark(useBookmarks.getState().items, path, line);
return [
{
label: on ? "Remove Bookmark" : "Add Bookmark",
shortcut: shortcutLabel("navigate.toggleBookmark"),
run: () => void executeAction("navigate.toggleBookmark", { path, line }),
},
];
},
}),
registerActions([
{
@@ -298,7 +319,8 @@ export function activate(): void | (() => void) {
onProject(s.project?.root ?? null);
resetTracking();
// A directory scope pointed into the previous project.
if (useFindPopup.getState().scopeTab === "directory") useFindPopup.setState({ scopeTab: "project", directory: "" });
if (useFindPopup.getState().scopeTab === "directory")
useFindPopup.setState({ scopeTab: "project", directory: "" });
}),
onIdeEvent("fsChanged", (e) => {
if (!e.removed.length) return;
+23 -3
Переглянути файл
@@ -4,21 +4,23 @@
* merge and Local History editors, remote operations.
*/
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { registerEditorContribution } from "@/editor/api";
import { editorsFor, registerEditorContribution } from "@/editor/api";
import {
notifications,
registerEditorKind,
registerMenuGroup,
registerOverlay,
registerTitleItem,
registerToolWindow,
} from "@/workbench/contributions";
import { executeAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { lazyComponent } from "@/ui";
import { registerVcsActions } from "./actions";
import { installChangelists } from "./changelists";
import { registerHistoryActions, trackOperationContext } from "./historyActions";
import { controlLabel, controlOperation, inProgress } from "./operations";
import { blameSlot } from "./blame";
import { annotationsShown, blameSlot } from "./blame";
import { BranchWidget, useBranchesPopup } from "./BranchWidget";
import { showConflictsDialog } from "./conflictsUi";
import { registerCompareActions } from "./compare";
@@ -103,7 +105,10 @@ export function activate(): () => void {
installIncomingCheck(),
installFileConfirmations(),
// Settings › Git › Sign-off commit: the default of Commit Options › Sign-off Commit.
onWorkflowSettingsChange((w) => w.vcs.git.signOff, (signOff) => useCommit.setState({ signOff })),
onWorkflowSettingsChange(
(w) => w.vcs.git.signOff,
(signOff) => useCommit.setState({ signOff }),
),
registerVcsActions(),
registerHistoryActions(),
registerLocalHistoryActions(),
@@ -159,6 +164,21 @@ export function activate(): () => void {
order: 60,
extensions: (ctx) => [changeMarkers(ctx), blameSlot()],
}),
registerMenuGroup({
id: "vcs.gutter",
menus: ["editorGutter"],
order: 10,
items: (_menu, { paths: [path] }) => {
if (!path || !getContext().vcsAvailable) return [];
const shown = editorsFor(path).some((h) => annotationsShown(h.view));
return [
{
label: shown ? "Close Annotations" : "Annotate with Git Blame",
run: () => void executeAction("vcs.annotate", path),
},
];
},
}),
];
return () => {
onConflictsAppeared(() => undefined);
+3 -3
Переглянути файл
@@ -510,9 +510,9 @@ export function projectViewRoots(map: ReadonlyMap<string, ProjectViewRootContrib
// ---------------------------------------------------------------------------
// Context menu groups: features add items to the editor, project view and
// editor tab context menus (IDEA's EditorPopupMenu / ProjectViewPopupMenu /
// EditorTabPopupMenu groups), e.g. "Run 'hi.ts'" from features/run.
// EditorTabPopupMenu / EditorGutterPopupMenu groups), e.g. "Run 'hi.ts'" from features/run.
export type ContextMenuId = "editor" | "projectView" | "editorTab";
export type ContextMenuId = "editor" | "projectView" | "editorTab" | "editorGutter";
export interface MenuGroupContext {
/** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */
@@ -521,7 +521,7 @@ export interface MenuGroupContext {
dirs?: boolean[];
/** The document text, when the menu is for an open document. */
text?: string;
/** Editor menu: 0-based caret line. */
/** Editor menu: 0-based caret line; gutter menu: the clicked line. */
line?: number;
}