дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
f5e8d77dcf
коміт
be5a6f62ad
6 files changed
+126
-64
No files matched your search
@@ -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") },
|
||||
];
|
||||
}
|
||||
@@ -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) };
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача